diff --git a/agents/components/Profile.js b/agents/components/Profile.js index aa31f5f..8ae05ff 100644 --- a/agents/components/Profile.js +++ b/agents/components/Profile.js @@ -1,118 +1,109 @@ -import PropTypes from 'prop-types' import React from 'react' import { connect as connectFela } from 'react-fela' import { Field, reduxForm as connectForm } from 'redux-form' -import { pipe } from 'ramda' +import { pipe, isNil, not } from 'ramda' import { TextField } from 'redux-form-material-ui' import RaisedButton from 'material-ui/RaisedButton' +import { compose, withState, withHandlers } from 'recompose' +import h from 'react-hyperscript' import { FormattedMessage } from '../../lib/Intl' import styles from '../styles/Profile' import Button from '../../app/components/Button' import AvatarField from '../../app/components/AvatarField' -class Profile extends React.Component { - constructor (props, context) { - super(props, context) - this.state = { - isEditing: false - } - } - - toggleEdit () { - this.setState({ - isEditing: !this.state.isEditing - }) - } - - render () { - const { isEditing } = this.state - const { styles, agent, agent: { profile: { name, description, avatar } } } = this.props - - return ( -
-

- -

-
-
- -
-
- - } - component={TextField} - fullWidth={true} - value={name} - disabled={!isEditing} - /> - - } - component={TextField} - value={description} - fullWidth={true} - multiLine={true} - rowsMax={5} - disabled={!isEditing} - /> -
-
-
- { this.toggleEdit() }}> - { - isEditing - ? - : - } - -
-
- ) - } - -} - -Profile.propTypes = { - agent: PropTypes.shape({ - profile: PropTypes.shape({ - avatar: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - name: PropTypes.string.isRequired - }).isRequired - }) -} +function Profile (props) { + const { styles, isEditing, toggleEdit, agent } = props + if (isNil(agent)) return null + const { profile } = agent + if (isNil(profile)) return null + const { name, description, avatar } = profile -Profile.defaultProps = { + return h('form', { + className: styles.container + }, [ + h('p', { + className: styles.intro + }, [ + h(FormattedMessage, { + id: 'agents.profile', + className: styles.labelText + }) + ]), + h('div', { + className: styles.innerContainer + }, [ + h('div', { + className: styles.avatarContainer + }, [ + h(Field, { + name: 'avatar', + component: AvatarField, + isEditingProfile: isEditing, + value: avatar + }) + ]), + h('div', { + className: styles.infoContainer + }, [ + h(Field, { + name: 'name', + floatingLabelText: ( + h(FormattedMessage, { + id: 'agents.nameLabel', + className: styles.labelText + }) + ), + component: TextField, + fullWidth: true, + value: name, + disabled: not(isEditing) + }), + h(Field, { + name: 'description', + floatingLabelText: ( + h(FormattedMessage, { + id: 'agents.descriptionLabel', + className: styles.labelText + }) + ), + component: TextField, + value: description, + fullWidth: true, + multiLine: true, + rowsMax: 5, + disabled: not(isEditing) + }) + ]) + ]), + h('div', { + className: styles.buttonContainer + }, [ + h(RaisedButton, { + className: styles.button, + type: 'button', + onClick: () => toggleEdit() + }, [ + isEditing + ? h(FormattedMessage, { + id: 'agents.saveProfile', + className: styles.labelText + }) + : h(FormattedMessage, { + id: 'agents.editProfile', + className: styles.labelText + }) + ]) + ]) + ]) } -export default pipe( +export default compose( connectFela(styles), + withState('isEditing', 'setEditing', false), + withHandlers({ + toggleEdit: ({ setEditing }) => () => setEditing(not) + }), connectForm({ form: 'profile', initialValues: { diff --git a/agents/getters/getAgents.js b/agents/getters/getAgents.js deleted file mode 100644 index f8b8e80..0000000 --- a/agents/getters/getAgents.js +++ /dev/null @@ -1,3 +0,0 @@ -const getAgents = (state) => state.agents - -export default getAgents diff --git a/app/containers/Dashboard.js b/app/containers/Dashboard.js index 1b9dfe2..51578de 100644 --- a/app/containers/Dashboard.js +++ b/app/containers/Dashboard.js @@ -3,7 +3,7 @@ import { connect } from 'feathers-action-react' import Dashboard from '../components/Dashboard' import { actions as taskPlanActions } from '../../tasks/dux/plans' import { actions as taskWorkActions } from '../../tasks/dux/works' -import * as orderingActions from '../../ordering/actions' +import { actions as orderActions } from '../../ordering/dux/orders' import getDashboardProps from '../getters/getDashboardProps' export default connect({ @@ -11,7 +11,7 @@ export default connect({ actions: { taskPlans: taskPlanActions, taskWorks: taskWorkActions, - ordering: orderingActions + orders: orderActions }, query: [ { diff --git a/app/getters/getDashboardProps.js b/app/getters/getDashboardProps.js index 7625513..1b3846d 100644 --- a/app/getters/getDashboardProps.js +++ b/app/getters/getDashboardProps.js @@ -1,9 +1,9 @@ import { createStructuredSelector } from 'reselect' -import getTaskPlans from '../../tasks/getters/getTaskPlans' +import getParentTaskPlans from '../../tasks/getters/getParentTaskPlans' const getDashboardProps = createStructuredSelector({ - taskPlans: getTaskPlans + taskPlans: getParentTaskPlans }) export default getDashboardProps diff --git a/db/migrations/20170721153408_add-Params-to-taskPlans.js b/db/migrations/20170721153408_add-Params-to-taskPlans.js new file mode 100644 index 0000000..5391189 --- /dev/null +++ b/db/migrations/20170721153408_add-Params-to-taskPlans.js @@ -0,0 +1,11 @@ +exports.up = function (knex, Promise) { + return knex.schema.table('taskPlans', function (table) { + table.json('params').references('taskPlans.id') + }) +} + +exports.down = function (knex, Promise) { + return knex.schema.table('taskPlans', function (table) { + table.dropColumn('params') + }) +} diff --git a/db/migrations/20170725145631_create-orders-table.js b/db/migrations/20170725145631_create-orders-table.js new file mode 100644 index 0000000..e0b491b --- /dev/null +++ b/db/migrations/20170725145631_create-orders-table.js @@ -0,0 +1,10 @@ +exports.up = function (knex, Promise) { + return knex.schema.createTableIfNotExists('orders', function (table) { + table.increments('id') + table.integer('agentId').references('agents.id') + }) +} + +exports.down = function (knex, Promise) { + return knex.schema.dropTableIfExists('orders') +} diff --git a/db/migrations/20170725155010_fix-task-assignees.js b/db/migrations/20170725155010_fix-task-assignees.js new file mode 100644 index 0000000..b91097f --- /dev/null +++ b/db/migrations/20170725155010_fix-task-assignees.js @@ -0,0 +1,13 @@ +exports.up = function (knex, Promise) { + return knex.schema.table('taskPlans', function (table) { + table.dropColumn('assignee') + table.integer('assigneeId').references('assignee.id') + }) +} + +exports.down = function (knex, Promise) { + return knex.schema.table('taskPlans', function (table) { + table.string('assignee').notNullable() + table.dropColumn('assigneeId') + }) +} diff --git a/epic.js b/epic.js index a3ac308..20c8fbf 100644 --- a/epic.js +++ b/epic.js @@ -1,13 +1,13 @@ import { combineEpics } from 'redux-observable' import { epic as agents } from 'dogstack-agents' -import ordering from './ordering/epic' import { epic as taskPlans } from './tasks/dux/plans' import { epic as taskWorks } from './tasks/dux/works' +import { epic as orders } from './ordering/dux/orders' export default combineEpics( agents, - ordering, + orders, taskPlans, taskWorks ) diff --git a/ordering/components/DashboardOrders.js b/ordering/components/DashboardOrders.js index b147ca3..293f454 100644 --- a/ordering/components/DashboardOrders.js +++ b/ordering/components/DashboardOrders.js @@ -21,7 +21,7 @@ function DashboardOrders (props) { actions.orders.create({})} > { - const currentAgent = getCurrentAgent(store.getState()) - const newGroupAgent = { type: 'group' } - // TODO (mw) creating a group should create an admin - // relationship with the person creating the group. - // - // TODO (mw) new task plans should be assigned to the group admins. - return Rx.Observable.concat( - Rx.Observable.of(agents.create(cid, newGroupAgent)), - createTaskPlan(action$, cid, { - taskRecipe: taskRecipes.finishPrereqs, - assignee: currentAgent.id - }) - ) - }) -} diff --git a/ordering/services/orders.js b/ordering/services/orders.js new file mode 100644 index 0000000..4a3e4ea --- /dev/null +++ b/ordering/services/orders.js @@ -0,0 +1,68 @@ +const feathersKnex = require('feathers-knex') +const { iff } = require('feathers-hooks-common') +import { pipe, equals, length, isNil } from 'ramda' +import * as taskRecipes from '../../tasks/data/recipes' + +module.exports = function () { + const app = this + const db = app.get('db') + + const name = 'orders' + const options = { Model: db, name } + + app.use(name, feathersKnex(options)) + app.service(name).hooks(hooks) +} + +const hooks = { + before: { + create: [ + iff(hasNoGroupAgent, createGroupAgent) + ] + }, + after: { + create: [ + iff(hasOneOrder, createPrereqTaskPlan) + ] + }, + error: {} +} + +function createGroupAgent (hook) { + const agents = hook.app.service('agents') + return agents.create({ type: 'group' }) + .then((agent) => { + hook.data.agentId = agent.id + return hook + }) +} + +function hasNoGroupAgent (hook) { + return isNil(hook.data.agentId) +} + +const hasLengthOne = pipe(length, equals(1)) + +function hasOneOrder (hook) { + const orders = hook.app.service('orders') + const agentId = hook.data.agentId + return orders.find({ query: { agentId } }) + .then(hasLengthOne) +} + +function createPrereqTaskPlan (hook) { + const taskPlans = hook.app.service('taskPlans') + const taskRecipeId = taskRecipes.finishPrereqs.id + + // TODO: add beforeAll hook to get agent + // const assigneeId = hook.params.agent.id + + const assigneeId = hook.params.credential.agentId + const params = JSON.stringify({ + contextAgentId: hook.data.agentId + }) + return taskPlans.create({ taskRecipeId, params, assigneeId }) + .then(() => { + return hook + }) +} diff --git a/package.json b/package.json index f4d2610..9121475 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "deps": "dependency-check . client.js epic.js layout.js root.js routes.js server.js store.js style.js updater.js --detective precinct && dependency-check . client.js epic.js layout.js root.js routes.js server.js store.js style.js updater.js --extra --no-dev --detective precinct -i babelify -i babel-preset-es2015 -i babel-preset-react -i babel-plugin-ramda -i pg", "db": "dog db", "storybook": "start-storybook -p 6006", - "burnthemall": "rm -rf package-lock.json node_modules; npm i" + "burnthemall": "rm -rf package-lock.json node_modules; npm i", + "burnthedb": "rm db/dev.sqlite3; dog db migrate:latest; dog db seed:run" }, "browserify": { "transform": [ @@ -63,7 +64,7 @@ "bigmath": "^1.0.3", "dog-names": "^1.0.2", "dogstack": "^0.4.0", - "dogstack-agents": "^0.3.3", + "dogstack-agents": "^0.3.4", "feathers-action": "^2.2.0", "feathers-action-react": "github:ahdinosaur/feathers-action-react#cancel", "feathers-errors": "^2.6.2", diff --git a/routes.js b/routes.js index 43b4c87..3959be8 100644 --- a/routes.js +++ b/routes.js @@ -9,6 +9,8 @@ import Register from './agents/containers/Register' import SignIn from './agents/containers/SignIn' import LogOut from './agents/containers/LogOut' +import TaskWorker from './tasks/containers/TaskWorker' + import { SignOut } from 'dogstack-agents/components' @@ -72,5 +74,10 @@ export default [ selector: getIsNotAuthenticated, icon: 'fa fa-heart' } + }, + { + name: 'task', + path: '/tasks/:taskPlanId', + Component: UserIsAuthenticated(TaskWorker) } ] diff --git a/server.js b/server.js index 1e9de2f..0e9584b 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,8 @@ const services = [ require('dogstack-agents/service'), require('./tasks/services/plans'), - require('./tasks/services/works') + require('./tasks/services/works'), + require('./ordering/services/orders') ] export default { diff --git a/tasks/components/DashboardTasks.js b/tasks/components/DashboardTasks.js index 83362db..38bee8d 100644 --- a/tasks/components/DashboardTasks.js +++ b/tasks/components/DashboardTasks.js @@ -1,6 +1,6 @@ import React from 'react' import { connect as connectFela } from 'react-fela' -import { pipe, map, mapObjIndexed } from 'ramda' +import { pipe, map, values } from 'ramda' import RaisedButton from 'material-ui/RaisedButton' import { List, ListItem } from 'material-ui/List' import { Link } from 'react-router-dom' @@ -9,7 +9,7 @@ import styles from '../styles/DashboardTasks' import { FormattedMessage } from '../../lib/Intl' function DashboardTasks (props) { - const { styles, taskPlans } = props + const { styles, taskPlans = {} } = props const renderChildTask = (childTaskPlan) => { return ( @@ -23,11 +23,12 @@ function DashboardTasks (props) { ) } + const renderChildTasks = pipe(map(renderChildTask), values) const renderParentTask = (parentTaskPlan) => { return ( } > ) } + const renderParentTasks = pipe(map(renderParentTask), values) return (
@@ -47,7 +49,7 @@ function DashboardTasks (props) { />

- {map(renderParentTask, taskPlans)} + {renderParentTasks(taskPlans)}
) diff --git a/tasks/components/SetupGroupTask.js b/tasks/components/SetupGroupTask.js index 6ce64f2..6d4cb2e 100644 --- a/tasks/components/SetupGroupTask.js +++ b/tasks/components/SetupGroupTask.js @@ -8,7 +8,8 @@ import MemberInvites from '../../agents/components/MemberInvites' export default (props) => { const { taskPlan } = props - const { agent } = taskPlan + // TODO this is wrong + const { assignee: agent } = taskPlan const steps = [ { diff --git a/tasks/components/TaskWorker.js b/tasks/components/TaskWorker.js index 5d79f73..4c7dbde 100644 --- a/tasks/components/TaskWorker.js +++ b/tasks/components/TaskWorker.js @@ -20,6 +20,7 @@ const getTaskComponent = path(['taskRecipe', 'Component']) function TaskWorker (props) { const { styles, taskPlan, onNavigate, onComplete, onCancel } = props + if (isNil(taskPlan)) return null const { taskRecipe } = taskPlan const { id: taskRecipeId } = taskRecipe const childTaskPlans = getSubTaskPlans(taskPlan) diff --git a/tasks/containers/TaskWorker.js b/tasks/containers/TaskWorker.js new file mode 100644 index 0000000..d177395 --- /dev/null +++ b/tasks/containers/TaskWorker.js @@ -0,0 +1,70 @@ +import h from 'react-hyperscript' +import { isNil } from 'ramda' +import { bindActionCreators } from 'redux' +import { connect as connectRedux } from 'react-redux' +import { connect as connectFeathers } from 'feathers-action-react' +import { compose } from 'recompose' +import { push } from 'react-router-redux' + +import { actions as taskPlans } from '../dux/plans' +import { actions as taskWorks } from '../dux/works' +import getTaskWorkerProps from '../getters/getTaskWorkerProps' +import TaskWorker from '../components/TaskWorker' + +export default compose( + connectFeathers({ + selector: getTaskWorkerProps, + actions: { + taskPlans, + taskWorks, + // `feathers-action-react` wraps every + // action creator in a cid creator. + router: { + push: (cid, ...args) => push(...args) + } + }, + query: ({ taskPlanId }) => [ + { + service: 'taskPlans', + id: taskPlanId + }, + { + service: 'taskPlans', + params: { + query: { + parentTaskPlanId: taskPlanId + } + } + }, + { + service: 'taskWorks', + params: { + query: { + taskPlanId + } + } + } + // TODO how do we fetch child task works from task plan? + // need to re-query after the first one is done + ] + }) +)(props => { + const { currentTaskPlan: taskPlan, currentAgent: agent, actions } = props + + return h(TaskWorker, { + taskPlan, + onNavigate: handleNavigate, + onCancel: handleCancel + }) + + function handleNavigate (taskPlan) { + actions.router.push(`/tasks/${taskPlan.id}`) + } + + function handleCancel (taskPlan) { + const { parentTaskPlan } = taskPlan + const nextRoute = isNil(parentTaskPlan) + ? '/' : `/tasks/${parentTaskPlan.id}` + actions.router.push(nextRoute) + } +}) diff --git a/tasks/data/recipes.js b/tasks/data/recipes.js index 1c9cc45..fdba902 100644 --- a/tasks/data/recipes.js +++ b/tasks/data/recipes.js @@ -3,12 +3,14 @@ import SetupSupplierTask from '../components/SetupSupplierTask' export const setupGroup = { id: 'setupGroup', - Component: SetupGroupTask + Component: SetupGroupTask, + childTaskRecipes: [] } export const setupSupplier = { id: 'setupSupplier', - Component: SetupSupplierTask + Component: SetupSupplierTask, + childTaskRecipes: [] } export const finishPrereqs = { diff --git a/tasks/getters/getCurrentTaskPlan.js b/tasks/getters/getCurrentTaskPlan.js new file mode 100644 index 0000000..a631c8b --- /dev/null +++ b/tasks/getters/getCurrentTaskPlan.js @@ -0,0 +1,11 @@ +import { createSelector } from 'reselect' +import { prop } from 'ramda' + +import getTaskPlans from './getTaskPlans' +import getCurrentTaskPlanId from './getCurrentTaskPlanId' + +export default createSelector( + getCurrentTaskPlanId, + getTaskPlans, + prop +) diff --git a/tasks/getters/getCurrentTaskPlanId.js b/tasks/getters/getCurrentTaskPlanId.js new file mode 100644 index 0000000..753a942 --- /dev/null +++ b/tasks/getters/getCurrentTaskPlanId.js @@ -0,0 +1 @@ +export default (state, props) => props.match.params.taskPlanId diff --git a/tasks/getters/getEnhancedTaskPlans.js b/tasks/getters/getEnhancedTaskPlans.js index 2b8fd33..df3872e 100644 --- a/tasks/getters/getEnhancedTaskPlans.js +++ b/tasks/getters/getEnhancedTaskPlans.js @@ -1,7 +1,7 @@ import { createSelector } from 'reselect' import { map, merge } from 'ramda' -import getAgents from '../../agents/getters/getAgents' +import { getAgents } from 'dogstack-agents/getters' import getRawTaskPlans from './getRawTaskPlans' import getRawTaskRecipes from './getRawTaskRecipes' @@ -12,7 +12,7 @@ const getEnhancedTaskPlans = createSelector( (taskPlans, taskRecipes, agents) => { const enhanceTaskPlan = (taskPlan) => { const taskRecipe = taskRecipes[taskPlan.taskRecipeId] - const assignee = agents[taskPlan.assignee] + const assignee = agents[taskPlan.assigneeId] return merge(taskPlan, { taskRecipe, assignee diff --git a/tasks/getters/getTaskPlans.js b/tasks/getters/getTaskPlans.js index eeedb49..e52d883 100644 --- a/tasks/getters/getTaskPlans.js +++ b/tasks/getters/getTaskPlans.js @@ -17,7 +17,7 @@ const getTaskPlanTree = createSelector( // 3. resolve new node from soure list const taskPlan = taskPlansById[taskPlanId] - + if (isNil(taskPlan)) return null // 4. resolve childs from tree (these MUST exist in tree, which is guaranteed by doing nodes with no childs first) const childTaskPlans = (childTaskPlansByParentId[taskPlanId] || []).map(({ id }) => taskPlanTree[id]) diff --git a/tasks/getters/getTaskWorkerProps.js b/tasks/getters/getTaskWorkerProps.js new file mode 100644 index 0000000..126cda0 --- /dev/null +++ b/tasks/getters/getTaskWorkerProps.js @@ -0,0 +1,7 @@ +import { createStructuredSelector } from 'reselect' + +import getCurrentTaskPlan from './getCurrentTaskPlan' + +export default createStructuredSelector({ + currentTaskPlan: getCurrentTaskPlan +}) diff --git a/tasks/services/plans.js b/tasks/services/plans.js index e2946df..829b616 100644 --- a/tasks/services/plans.js +++ b/tasks/services/plans.js @@ -1,4 +1,7 @@ const feathersKnex = require('feathers-knex') +const { iff } = require('feathers-hooks-common') +import { isEmpty } from 'ramda' +import * as taskRecipes from '../../tasks/data/recipes' module.exports = function () { const app = this @@ -13,6 +16,32 @@ module.exports = function () { const hooks = { before: {}, - after: {}, + after: { + create: [ + iff(hasChildTasks, createChildTaskPlans) + ] + }, error: {} } + +function hasChildTasks (hook) { + const taskRecipe = taskRecipes[hook.data.taskRecipeId] + return !isEmpty(taskRecipe.childTaskRecipes) +} + +function createChildTaskPlans (hook) { + const taskPlans = hook.app.service('taskPlans') + const taskRecipe = taskRecipes[hook.data.taskRecipeId] + const childTaskRecipes = taskRecipe.childTaskRecipes + return Promise.all( + childTaskRecipes.map((childTaskRecipe) => { + return taskPlans.create({ + parentTaskPlanId: hook.result.id, + assigneeId: hook.data.assigneeId, + taskRecipeId: childTaskRecipe.id, + params: hook.data.params + }) + }) + ) + .then(() => hook) +} diff --git a/tasks/util/createTaskPlan.js b/tasks/util/createTaskPlan.js index 02fcbd3..e9b4927 100644 --- a/tasks/util/createTaskPlan.js +++ b/tasks/util/createTaskPlan.js @@ -1,49 +1,23 @@ -import Rx from 'rxjs' -import Cid from 'incremental-id' +import { unnest } from 'ramda' -import { actions as taskPlans } from '../dux/plans' - -export default function createTaskPlan (action$, cid, options) { - const { assignee, taskRecipe, parentTaskPlanId } = options +export default function createTaskPlan (options) { + const { assignee, taskRecipe } = options const { childTaskRecipes = [] } = taskRecipe const parentTaskPlan = { assignee, - taskRecipeId: taskRecipe.id, - parentTaskPlanId - } - - console.log('taskPlan', parentTaskPlan) - - var nextActions$ = Rx.Observable.of(taskPlans.create(cid, parentTaskPlan)) - - if (childTaskRecipes.length > 0) { - const parentTaskPlanSet$ = action$.ofType(taskPlans.set.type).filter(onlyCid).take(1) - - nextActions$ = nextActions$.concat( - parentTaskPlanSet$.mergeMap(action => { - return createChildTaskPlans(action.payload.data.id) - }) - ) + taskRecipeId: taskRecipe.id } - return nextActions$ - - function createChildTaskPlans (parentTaskPlanId) { - // (mw) need a sub-cid otherwise maybe epic will mess up? - return Rx.Observable.merge( - ...childTaskRecipes.map(childTaskRecipe => { - const subCid = `${cid}/${Cid()}` - return createTaskPlan(action$, subCid, { - assignee, - taskRecipe: childTaskRecipe, - parentTaskPlanId - }) - }) - ) - } - - function onlyCid (action) { - return action.meta.cid === cid - } + const childTaskPlans = childTaskRecipes.map(childTaskRecipe => { + return createTaskPlan({ + assignee, + taskRecipe: childTaskRecipe + }) + }) + + return unnest([ + parentTaskPlan, + ...childTaskPlans + ]) } diff --git a/updater.js b/updater.js index c106d3b..4abf005 100644 --- a/updater.js +++ b/updater.js @@ -5,6 +5,7 @@ import { reducer as formReducer } from 'redux-form' import { updater as agents } from 'dogstack-agents' import { updater as taskPlans } from './tasks/dux/plans' import { updater as taskWorks } from './tasks/dux/works' +import { updater as orders } from './ordering/dux/orders' import taskRecipes from './tasks/updaters/recipes' const router = updateStateAt('router', reducerToUpdater(routerReducer)) @@ -12,6 +13,7 @@ const form = updateStateAt('form', reducerToUpdater(formReducer)) export default concat( agents, + orders, taskPlans, taskWorks, taskRecipes,