Added Button and Action<entity>, also updated Todo app to use them.

This commit is contained in:
Martin Sosic 2020-03-01 17:20:14 +01:00 committed by Martin Šošić
parent fd39e5b658
commit e45d077f54
23 changed files with 386 additions and 51 deletions

View File

@ -0,0 +1,39 @@
{{={= =}=}}
import React from 'react'
import { connect } from 'react-redux'
import Button from '@material-ui/core/Button'
{=# onClickAction =}
import { {= exportedIdentifier =} } from '{= importPath =}'
{=/ onClickAction =}
export class {= button.name =} extends React.Component {
// TODO: Add propTypes.
{=# onClickAction =}
onClick = () => {
this.props.{= exportedIdentifier =}()
}
{=/ onClickAction =}
render() {
return (
<Button {...this.props}
{=# onClickAction =}
onClick={this.onClick}
{=/ onClickAction =}
>
{= button.label =}
</Button>
)
}
}
export default connect(state => ({
// Selectors
}), {
// Actions
{=# onClickAction =}
{= exportedIdentifier =}
{=/ onClickAction =}
})({= button.name =})

View File

@ -1,7 +1,7 @@
{{={= =}=}}
import uuidv4 from 'uuid/v4'
export default class {= entity.name =} {
export default class {= entityClassName =} {
_data = {}
constructor (data = {}) {

View File

@ -1,4 +1,5 @@
{{={= =}=}}
export const ADD = 'entities/{= entityLowerName =}/ADD'
export const SET = 'entities/{= entityLowerName =}/SET'
export const UPDATE = 'entities/{= entityLowerName =}/UPDATE'
export const REMOVE = 'entities/{= entityLowerName =}/REMOVE'

View File

@ -1,13 +1,23 @@
{{={= =}=}}
import * as types from './actionTypes'
import {=entityClassName=} from './{=entityClassName=}'
import { selectors } from './state'
/**
* @param {{= entity.name =}} {= entityLowerName =}
*/
export const add = ({= entityLowerName =}) => ({
export const add = ({=_entity=}) => ({
type: types.ADD,
data: {= entityLowerName =}.toData()
data: {=_entity=}.toData()
})
/**
* @param {{=entityClassName=}[]} {=_entities=}
*/
export const set = ({=_entities=}) => ({
type: types.SET,
{=_entities=}: {=_entities=}.map({=_e=} => {=_e=}.toData())
})
/**
@ -27,3 +37,13 @@ export const remove = (id) => ({
type: types.REMOVE,
id
})
{=# entityActions =}
export const {=name=} = () => (dispatch, getState) => {
const {=_entities=} = selectors.all(getState())
const updateFn = {=&updateFn=}
const new{=_Entities=} = updateFn({=_entities=}.map({=_e=} => {=_e=}.toData())).map({=_e=} => new {=entityClassName=}({=_e=}))
dispatch(set(new{=_Entities=}))
}
{=/ entityActions =}

View File

@ -1,13 +1,13 @@
{{={= =}=}}
import { createSelector } from 'reselect'
import {= entity.name =} from './{= entity.name =}'
import {=entityClassName=} from './{=entityClassName=}'
import * as types from './actionTypes'
// We assume that root reducer of the app will put this reducer under
// key ROOT_REDUCER_KEY.
const ROOT_REDUCER_KEY = 'entities/{= entity.name =}'
const ROOT_REDUCER_KEY = 'entities/{=entity.name=}'
const initialState = {
all: []
@ -21,14 +21,20 @@ const reducer = (state = initialState, action) => {
all: [ ...state.all, action.data ]
}
case types.SET:
return {
...state,
all: action.{=_entities=}
}
case types.UPDATE:
return {
...state,
all: state.all.map(
{= entityLowerName =} =>
{= entityLowerName =}.id === action.id
? { ...{= entityLowerName =}, ...action.data }
: {= entityLowerName =}
{=_entity=} =>
{=_entity=}.id === action.id
? { ...{=_entity=}, ...action.data }
: {=_entity=}
)
}
@ -36,7 +42,7 @@ const reducer = (state = initialState, action) => {
return {
...state,
all: state.all.filter(
{= entityLowerName =} => {= entityLowerName =}.id !== action.id
{=_entity=} => {=_entity=}.id !== action.id
)
}
@ -53,7 +59,7 @@ selectors.root = (state) => state[ROOT_REDUCER_KEY]
* @returns {{= entity.name =}[]}
*/
selectors.all = createSelector(selectors.root, (state) => {
return state.all.map(data => new {= entity.name =}(data))
return state.all.map(data => new {=entityClassName=}(data))
})

View File

@ -38,7 +38,7 @@ button.selected {
background-color: white;
border: none;
display: inline-block;
font-size: 24px;
font-size: 24px !important;
cursor: pointer;
}

View File

@ -7,24 +7,16 @@ import { connect } from 'react-redux'
// These will have well defined and documented APIs and paths.
// Note that Task, NewTaskForm and TaskList are generated based on the declarations
// we made in todoApp.wasp file.
import Task from '@wasp/entities/task/Task'
import NewTaskForm from '@wasp/entities/task/components/NewTaskForm'
import TaskList from '@wasp/entities/task/components/TaskList'
import * as taskState from '@wasp/entities/task/state.js'
import * as taskActions from '@wasp/entities/task/actions.js'
import ToggleIsDoneButton from '@wasp/components/ToggleIsDoneButton'
import DeleteDoneButton from '@wasp/components/DeleteDoneButton'
class Todo extends React.Component {
// TODO: prop types.
toggleIsDoneForAllTasks = () => {
const areAllDone = this.props.taskList.every(t => t.isDone)
this.props.taskList.map(t => this.props.updateTask(t.id, { isDone: !areAllDone }))
}
deleteCompletedTasks = () => {
this.props.taskList.map((t) => { if (t.isDone) this.props.removeTask(t.id) })
}
isAnyTaskCompleted = () => this.props.taskList.some(t => t.isDone)
isThereAnyTask = () => this.props.taskList.length > 0
@ -36,12 +28,10 @@ class Todo extends React.Component {
<h1> Todos </h1>
<div className="todos__toggleAndInput">
<button
<ToggleIsDoneButton
disabled={!this.isThereAnyTask()}
className="todos__toggleButton"
onClick={this.toggleIsDoneForAllTasks}>
</button>
/>
<NewTaskForm
className="todos__newTaskForm"
@ -51,9 +41,7 @@ class Todo extends React.Component {
</div>
{ this.isThereAnyTask() && (<>
<TaskList
editable
/>
<TaskList editable />
<div className="todos__footer">
<div className="todos__footer__itemsLeft">
@ -61,11 +49,9 @@ class Todo extends React.Component {
</div>
<div className="todos__footer__clearCompleted">
<button
className={this.isAnyTaskCompleted() ? '' : 'hidden' }
onClick={this.deleteCompletedTasks}>
Clear completed
</button>
<DeleteDoneButton
className={this.isAnyTaskCompleted() ? '' : 'hidden' }
/>
</div>
</div>
</>)}
@ -78,7 +64,5 @@ class Todo extends React.Component {
export default connect(state => ({
taskList: taskState.selectors.all(state)
}), {
addTask: taskActions.add,
updateTask: taskActions.update,
removeTask: taskActions.remove
addTask: taskActions.add
})(Todo)

View File

@ -52,3 +52,24 @@ entity-list<Task> TaskList {
active: {=js task => !task.isDone js=}
}
}
button ToggleIsDoneButton {
label: "✓",
onClick: toggleIsDoneAction
}
button DeleteDoneButton {
label: "Delete completed",
onClick: deleteDoneAction
}
action<Task> toggleIsDoneAction {=js
tasks => {
const areAllDone = tasks.every(t => t.isDone)
return tasks.map(t => ({ ...t, isDone: !areAllDone }))
}
js=}
action<Task> deleteDoneAction {=js
tasks => tasks.filter(t => !t.isDone)
js=}

View File

@ -0,0 +1,58 @@
module Generator.Button
( generateButtons
) where
import Data.Maybe (fromJust)
import Data.Aeson ((.=), object)
import qualified Data.Aeson as Aeson
import qualified System.FilePath as FP
import Path ((</>), relfile, reldir)
import qualified Path
import qualified Path.Aliases as Path
import qualified Path.Extra as Path
import Wasp (Wasp)
import qualified Wasp
import qualified Wasp.Button as WButton
import Generator.FileDraft (FileDraft, createTemplateFileDraft)
import qualified Generator.Entity
import qualified Generator.Common as Common
generateButtons :: Wasp -> [FileDraft]
generateButtons wasp = concatMap (generateButton wasp) (Wasp.getButtons wasp)
generateButton :: Wasp -> WButton.Button -> [FileDraft]
generateButton wasp button =
[ generateButtonComponent wasp button
]
generateButtonComponent :: Wasp -> WButton.Button -> FileDraft
generateButtonComponent wasp button = createTemplateFileDraft dstPath srcPath (Just templateData)
where
srcPath = [reldir|src|] </> [reldir|components|] </> [relfile|_Button.js|]
dstPath = Common.srcDirPath </> buttonDirPathInSrc </> (fromJust $ Path.parseRelFile $ (WButton._name button) ++ ".js")
onClickActionData :: Maybe Aeson.Value
onClickActionData = do
actionName <- WButton._onClickActionName button
action <- Wasp.getActionByName wasp actionName
let (pathInSrc, exportedIdentifier) = Generator.Entity.getImportInfoForAction wasp action
return $ object [ "importPath" .= buildImportPathFromPathInSrc pathInSrc
, "exportedIdentifier" .= exportedIdentifier
]
templateData = object $
[ "wasp" .= wasp
, "button" .= button
]
++ maybe [] (\d -> ["onClickAction" .= d]) onClickActionData
buttonDirPathInSrc :: Path.RelDir
buttonDirPathInSrc = [reldir|components|]
-- | Takes path relative to the src path of generated project and turns it into relative path that can be
-- used as "from" part of the import in the button component source file.
-- NOTE: Here we return FilePath instead of Path because we need stuff like "./" or "../" in the path,
-- which Path would normalize away.
buildImportPathFromPathInSrc :: Path.Path Path.Rel a -> FilePath
buildImportPathFromPathInSrc pathInSrc = (Path.reversePath buttonDirPathInSrc) FP.</> (Path.toFilePath pathInSrc)

View File

@ -6,6 +6,8 @@ module Generator.Entity
, entityStatePathInSrc
, entityActionsPathInSrc
, getImportInfoForAction
-- EXPORTED FOR TESTING:
, generateEntityClass
, generateEntityState
@ -16,12 +18,15 @@ module Generator.Entity
, entityTemplatesDirPath
) where
import qualified Data.Aeson as Aeson
import Data.Maybe (fromJust)
import Path ((</>), relfile)
import qualified Path
import qualified Path.Aliases as Path
import Util (jsonSet)
import Wasp
import qualified Wasp.Action
import Generator.FileDraft
import qualified Generator.Common as Common
import Generator.Entity.EntityForm (generateEntityCreateForm)
@ -59,7 +64,23 @@ generateEntityActionTypes wasp entity
generateEntityActions :: Wasp -> Entity -> FileDraft
generateEntityActions wasp entity
= createSimpleEntityFileDraft wasp entity (entityActionsPathInSrc entity) [relfile|actions.js|]
= createEntityFileDraft (entityActionsPathInSrc entity) [relfile|actions.js|] (Just templateData)
where
entityActions = getActionsForEntity wasp entity
templateData = jsonSet "entityActions" (Aeson.toJSON entityActions) (entityTemplateData wasp entity)
-- | Provides information on how to import and use given action.
-- Returns: (path (in src dir) to import action from, identifier under which it is exported).
-- NOTE: This function is in this module because this is where logic for generating action is,
-- but ideally that would move to more-standalone action generator and so would this function.
getImportInfoForAction :: Wasp -> Wasp.Action.Action -> (Path.RelFile, String)
getImportInfoForAction wasp action = (pathInSrc, exportedIdentifier)
where
-- NOTE: For now here we bravely assume that entity with such name exists.
Just entity = Wasp.getEntityByName wasp $ Wasp.Action._entityName action
pathInSrc = entityActionsPathInSrc entity
exportedIdentifier = Wasp.Action._name action
generateEntityComponents :: Wasp -> Entity -> [FileDraft]
generateEntityComponents wasp entity = concat
@ -82,11 +103,16 @@ generateEntityLists wasp entity = map (generateEntityList wasp) entityLists
-- | Helper function that captures common logic for generating entity file draft.
createSimpleEntityFileDraft :: Wasp -> Entity -> Path.RelFile -> Path.RelFile -> FileDraft
createSimpleEntityFileDraft wasp entity dstPathInSrc srcPathInEntityTemplatesDir
= createTemplateFileDraft dstPath srcPath (Just templateData)
= createEntityFileDraft dstPathInSrc srcPathInEntityTemplatesDir (Just templateData)
where
templateData = entityTemplateData wasp entity
createEntityFileDraft :: Path.RelFile -> Path.RelFile -> Maybe Aeson.Value -> FileDraft
createEntityFileDraft dstPathInSrc srcPathInEntityTemplatesDir maybeTemplateData =
createTemplateFileDraft dstPath srcPath maybeTemplateData
where
srcPath = entityTemplatesDirPath </> srcPathInEntityTemplatesDir
dstPath = Common.srcDirPath </> dstPathInSrc
templateData = entityTemplateData wasp entity
-- * Paths of generated code (relative to src/ directory)

View File

@ -39,16 +39,28 @@ entityTemplateData wasp entity = object
[ "wasp" .= wasp
, "entity" .= entity
, "entityLowerName" .= getEntityLowerName entity
, "entityUpperName" .= getEntityUpperName entity
-- TODO: use it also when creating Class file itself and in other files.
, "entityClassName" .= getEntityClassName entity
, "entityTypedFields" .= map entityFieldToJsonWithTypeAsKey (entityFields entity)
-- Below are shorthands, so that templates are more readable.
-- Each one has comment example for Task entity.
, "_entity" .= getEntityLowerName entity -- task
, "_entities" .= ((getEntityLowerName entity) ++ "s") -- tasks
, "_Entity" .= getEntityUpperName entity -- Task
, "_Entities" .= ((getEntityUpperName entity) ++ "s") -- Tasks
, "_e" .= [head $ getEntityLowerName entity] -- t
, "_es" .= ((head $ getEntityLowerName entity) : "s") -- ts
]
getEntityLowerName :: Entity -> String
getEntityLowerName = Util.toLowerFirst . entityName
getEntityUpperName :: Entity -> String
getEntityUpperName = Util.toUpperFirst . entityName
getEntityClassName :: Entity -> String
getEntityClassName = Util.toUpperFirst . entityName
getEntityClassName = getEntityUpperName
{- | Converts entity field to a JSON where field type is a key set to true, along with
all other field properties.

View File

@ -14,6 +14,7 @@ import Generator.FileDraft
import qualified Generator.Entity as EntityGenerator
import qualified Generator.PageGenerator as PageGenerator
import qualified Generator.ExternalCode as ExternalCodeGenerator
import qualified Generator.Button
import qualified Generator.Common as Common
@ -63,6 +64,7 @@ generateSrcDir wasp
]
++ PageGenerator.generatePages wasp
++ EntityGenerator.generateEntities wasp
++ Generator.Button.generateButtons wasp
++ [generateReducersJs wasp]
generateReducersJs :: Wasp -> FileDraft

View File

@ -19,6 +19,12 @@ reservedNameApp = "app"
reservedNamePage :: String
reservedNamePage = "page"
reservedNameButton :: String
reservedNameButton = "button"
reservedNameAction :: String
reservedNameAction = "action"
reservedNameEntity :: String
reservedNameEntity = "entity"
@ -51,6 +57,8 @@ reservedNames =
, reservedNamePage
, reservedNameEntity
, reservedNameEntityForm
, reservedNameButton
, reservedNameAction
-- * Data types
, reservedNameString
, reservedNameBoolean

View File

@ -15,6 +15,8 @@ import Parser.Entity (entity)
import Parser.Entity.EntityForm (entityForm)
import Parser.Entity.EntityList (entityList)
import Parser.JsImport (jsImport)
import Parser.Button (button)
import Parser.Action (action)
import Parser.Common (runWaspParser)
@ -25,10 +27,18 @@ waspElement
<|> waspElementEntity
<|> waspElementEntityForm
<|> waspElementEntityList
<|> waspElementButton
<|> waspElementAction
waspElementApp :: Parser Wasp.WaspElement
waspElementApp = Wasp.WaspElementApp <$> app
waspElementButton :: Parser Wasp.WaspElement
waspElementButton = Wasp.WaspElementButton <$> button
waspElementAction :: Parser Wasp.WaspElement
waspElementAction = Wasp.WaspElementAction <$> action
waspElementPage :: Parser Wasp.WaspElement
waspElementPage = Wasp.WaspElementPage <$> page

View File

@ -0,0 +1,21 @@
module Parser.Action
( action
) where
import Text.Parsec.String (Parser)
import qualified Wasp.Action as Action
import qualified Parser.Common as Common
import qualified Parser.JsCode as JsCode
import qualified Lexer as L
action :: Parser Action.Action
action = do
(entityName, actionName, updateFn) <- Common.waspElementLinkedToEntity L.reservedNameAction JsCode.jsCode
return Action.Action
{ Action._name = actionName
, Action._entityName = entityName
, Action._updateFn = updateFn
}

View File

@ -0,0 +1,44 @@
module Parser.Button
( button
) where
import Text.Parsec
import Text.Parsec.String (Parser)
import qualified Lexer as L
import qualified Wasp.Button as Button
import Parser.Common
data Property
= Label !String
| OnClick !String -- ^ Name of action to execute on click.
deriving (Show, Eq)
-- | Parses Button properties, separated by a comma.
properties :: Parser [Property]
properties = L.commaSep1 $
propLabel
<|> propOnClickActionName
propLabel :: Parser Property
propLabel = Label <$> waspPropertyStringLiteral "label"
propOnClickActionName :: Parser Property
propOnClickActionName = OnClick <$> waspProperty "onClick" L.identifier
getLabel :: [Property] -> String
getLabel ps = head $ [c | Label c <- ps]
getOnClickActionName :: [Property] -> Maybe String
getOnClickActionName ps = let actions = [a | OnClick a <- ps]
in if null actions then Nothing else Just (head actions)
button :: Parser Button.Button
button = do
(buttonName, buttonProps) <- waspElementNameAndClosure L.reservedNameButton properties
return Button.Button
{ Button._name = buttonName
, Button._label = getLabel buttonProps
, Button._onClickActionName = getOnClickActionName buttonProps
}

View File

@ -50,18 +50,17 @@ waspElementNameAndClosure elementType closure =
return (elementName, closureContent)
-- | Parses declaration of a wasp element linked to an entity.
-- E.g. "entity-form<Task> {...}" or "entity-list<Task> {...}"
-- E.g. "entity-form<Task> ..." or "action<Task> ..."
waspElementLinkedToEntity
:: String -- ^ Type of the linked wasp element (e.g. "entity-form").
-> Parser a -- ^ Parser to be used for parsing closure content of the wasp element.
-> Parser (String, String, a) -- ^ Name of the linked entity, element name and closure content.
waspElementLinkedToEntity elementType closure = do
-> Parser a -- ^ Parser to be used for parsing body of the wasp element.
-> Parser (String, String, a) -- ^ Name of the linked entity, element name and body.
waspElementLinkedToEntity elementType bodyParser = do
L.reserved elementType
linkedEntityName <- L.angles L.identifier
elementName <- L.identifier
closureContent <- waspClosure closure
return (linkedEntityName, elementName, closureContent)
body <- bodyParser
return (linkedEntityName, elementName, body)
-- | Parses wasp property along with the key, "key: value".
waspProperty :: String -> Parser a -> Parser a

View File

@ -26,7 +26,7 @@ import qualified Lexer as L
entityForm :: Parser EntityForm
entityForm = do
(entityName, formName, options) <-
P.waspElementLinkedToEntity L.reservedNameEntityForm entityFormOptions
P.waspElementLinkedToEntity L.reservedNameEntityForm (P.waspClosure entityFormOptions)
return WEF.EntityForm
{ WEF._name = formName

View File

@ -22,7 +22,7 @@ import qualified Lexer as L
entityList :: Parser EntityList
entityList = do
(entityName, listName, options) <-
P.waspElementLinkedToEntity L.reservedNameEntityList entityListOptions
P.waspElementLinkedToEntity L.reservedNameEntityList (P.waspClosure entityListOptions)
return WEL.EntityList
{ WEL._name = listName

View File

@ -20,6 +20,14 @@ module Wasp
, getEntityFormsForEntity
, getEntityListsForEntity
, getButtons
, addButton
, getActions
, addAction
, getActionsForEntity
, getActionByName
, module Wasp.Page
, getPages
, addPage
@ -37,6 +45,9 @@ import qualified Wasp.EntityForm as EF
import qualified Wasp.EntityList as EL
import Wasp.JsImport
import Wasp.Page
import Wasp.Button
import Wasp.Action (Action)
import qualified Wasp.Action
import qualified Util as U
@ -54,6 +65,8 @@ data WaspElement
| WaspElementEntity !Entity
| WaspElementEntityForm !EF.EntityForm
| WaspElementEntityList !EL.EntityList
| WaspElementButton !Button
| WaspElementAction !Action
deriving (Show, Eq)
fromWaspElems :: [WaspElement] -> Wasp
@ -108,6 +121,33 @@ getPages wasp = [page | (WaspElementPage page) <- waspElements wasp]
addPage :: Wasp -> Page -> Wasp
addPage wasp page = wasp { waspElements = (WaspElementPage page):(waspElements wasp) }
-- * Button
getButtons :: Wasp -> [Button]
getButtons wasp = [button | (WaspElementButton button) <- waspElements wasp]
addButton :: Wasp -> Button -> Wasp
addButton wasp button = wasp { waspElements = (WaspElementButton button):(waspElements wasp) }
-- * Action
getActions :: Wasp -> [Action]
getActions wasp = [action | (WaspElementAction action) <- waspElements wasp]
addAction :: Wasp -> Action -> Wasp
addAction wasp action = wasp { waspElements = (WaspElementAction action):(waspElements wasp) }
-- | Gets action with a specified name from wasp, if such an action exists.
-- We assume here that there are no two actions with same name.
getActionByName :: Wasp -> String -> Maybe Action
getActionByName wasp name = U.headSafe $ filter (\a -> Wasp.Action._name a == name) (getActions wasp)
-- | Retrieves all actions that are performed on specific entity.
getActionsForEntity :: Wasp -> Entity -> [Action]
getActionsForEntity wasp entity = filter isActionOfGivenEntity (getActions wasp)
where
isActionOfGivenEntity action = entityName entity == Wasp.Action._entityName action
-- * Entities
getEntities :: Wasp -> [Entity]

26
waspc/src/Wasp/Action.hs Normal file
View File

@ -0,0 +1,26 @@
module Wasp.Action
( Action(..)
) where
import Data.Aeson ((.=), object, ToJSON(..))
import Wasp.JsCode (JsCode)
-- Although name is general (Action), we are actually making many implicit assumptions here:
-- That Action is an update action that updates the whole collection of certain entity at once.
-- We are doing this because it is general enough for our purposes right now, but in the future we
-- will want to have much more developed system of actions, and Action will probably only be the
-- umbrella name/type for all of the action types.
data Action = Action
{ _name :: !String
, _entityName :: !String
-- | Js is expected to be function that takes list of entities and returns new list of entities.
, _updateFn :: !JsCode
} deriving (Show, Eq)
instance ToJSON Action where
toJSON action = object
[ "name" .= _name action
, "entityName" .= _entityName action
, "updateFn" .= _updateFn action
]

18
waspc/src/Wasp/Button.hs Normal file
View File

@ -0,0 +1,18 @@
module Wasp.Button
( Button(..)
) where
import Data.Aeson ((.=), object, ToJSON(..))
data Button = Button
{ _name :: !String
, _label :: !String
, _onClickActionName :: !(Maybe String)
} deriving (Show, Eq)
instance ToJSON Button where
toJSON button = object
[ "name" .= _name button
, "label" .= _label button
, "onClickActionName" .= _onClickActionName button
]

View File

@ -13,7 +13,7 @@ spec_parseWaspCommon :: Spec
spec_parseWaspCommon = do
describe "Parsing wasp element linked to an entity" $ do
it "When given a valid declaration, parses it correctly." $ do
runWaspParser (waspElementLinkedToEntity "entity-form" whiteSpace) "entity-form<Task> TaskForm { }"
runWaspParser (waspElementLinkedToEntity "entity-form" (waspClosure whiteSpace)) "entity-form<Task> TaskForm { }"
`shouldBe` Right ("Task", "TaskForm", ())
describe "Parsing wasp element name and properties" $ do