Skip to content

Instantly share code, notes, and snippets.

@ndugger
Last active October 29, 2019 18:13
Show Gist options
  • Select an option

  • Save ndugger/35ed65eab9440d6bd156a4a083cb130f to your computer and use it in GitHub Desktop.

Select an option

Save ndugger/35ed65eab9440d6bd156a4a083cb130f to your computer and use it in GitHub Desktop.
Custom React Hook: useWorkflow

Custom React Hook: useWorkflow

Used to organize code into groups of related hooks; have your component depend on a single workflow instead of its potentially many dependent hooks.

For ordered workflows, make sure to supply an order to each workflow step. If ordered, activeStep can be used to check which step the workflow is still waiting on.

import { useAuth } from '../useAuth';
import { usePermissions } from '../usePermissions';
import { useForm } from '../useForm';
import { createWorkflow, useWorkflow } from './useWorkflow';
const exampleWorkflow = createWorkflow({
isUserAuthorized: {
order: 0,
useRule: () => {
const user = useAuth();
const permissions = usePermissions();
return user.isAuthenticated && permissions.isAuthorized;
}
},
isTaskAssigned: {
order: 1,
useRule: () => {
const form = useForm();
return form.isTextEntered;
}
}
});
function ExampleComponent() {
const workflow = useWorkflow(exampleWorkflow);
if (workflow.isComplete) {
return (
<h1>
Workflow is complete!
</h1>
);
}
return (
<h2>
Workflow is in progress.
</h2>
);
}
import { createWorkflow, useWorkflow } from './useWorkflow';
const exampleWorkflowDefinition = {
isFoo: {
order: 2,
useRule: () => false,
},
isBar: {
useRule: () => true,
},
isBaz: {
order: 0,
useRule: () => true,
},
isBoo: {
order: 1,
useRule: () => true,
},
};
describe('createWorkflow', () => {
test('convert rules definition into workflow object', () => {
const exampleWorkflow = createWorkflow(exampleWorkflowDefinition);
expect(exampleWorkflow.process).toEqual(['isBaz', 'isBoo', 'isFoo', 'isBar']);
expect(exampleWorkflow.rules).toBe(exampleWorkflowDefinition);
});
test('disallow `isComplete` as step (reserved)', () => {
const withIsComplete = Object.assign({}, exampleWorkflowDefinition, {
isComplete: {
order: -1,
useRule: () => false,
},
});
expect(() => createWorkflow(withIsComplete)).toThrow();
});
test('disallow `activeStep` as step (reserved)', () => {
const withActiveStep = Object.assign({}, exampleWorkflowDefinition, {
activeStep: {
order: -1,
useRule: () => false,
},
});
expect(() => createWorkflow(withActiveStep)).toThrow();
});
});
describe('useWorkflow', () => {
test('calculate and return workflow status', () => {
const exampleWorkflow = createWorkflow(exampleWorkflowDefinition);
const workflow = useWorkflow(exampleWorkflow);
expect(workflow.activeStep).toEqual(1);
expect(workflow.isFoo).toEqual(false);
expect(workflow.isBar).toEqual(true);
expect(workflow.isBaz).toEqual(true);
expect(workflow.isComplete).toEqual(false);
});
});
/**
* #### Tests
* - **[[createWorkflow]]**
* - convert rules definition into workflow object
* - disallow `isComplete` as step (reserved)
* - disallow `activeStep` as step (reserved)
* - **[[useWorkflow]]**
* - calculate and return workflow status
*/
/**
* Workflow steps definition.
*/
interface WorkflowRule {
/**
* Allows creation of ordered workflow.
*/
order?: number;
/**
* Each rule has own hook; should depend on other hooks to drive workflow.
* As other hooks update, workflow will update its status.
*
* #### Example:
* ```ts
* useRule: () => {
* const user = useAuth();
* const permissions = usePermissions(user);
*
* return user.isAuthenticated && permissions.isAdmin;
* }
* ```
*/
useRule: () => boolean;
}
/**
* Contains and maintains rules and order of execution;
* React hooks **must** always be called in consistent order.
*
* #### Example:
* ```ts
* {
* process: [],
* rules: {
* isStepOneComplete: {
* order: 0,
* useRule: () => {
* // ...
* }
* }
* }
* }
* ```
*
* @typeparam Steps Definition of un/ordered workflow rules.
* - **key**: string.
* - **value**: [[WorkflowRule]].
*/
interface Workflow<Steps extends object = object> {
/**
* Sorted (by order) step names.
*/
process: (keyof Steps)[];
/**
* See `Steps` definition.
*/
rules: {
[Key in keyof Steps]: WorkflowRule;
};
}
/**
* Key/Value pair where keys are names of workflow steps, values are boolean.
* Also contains `isComplete` to indicate overall workflow completion.
*/
type WorkflowStatus<Steps extends object = object> = { activeStep: number; isComplete: boolean } & {
[Key in keyof Steps]: boolean;
};
/**
* Creates a workflow to be used in [[useWorkflow]].
*
* #### Example:
* ```ts
* const exampleWorkflow = createWorkflow({
* isItemDesignerAssigned: {
* order: 0,
* useRule: () => {
*
* }
* },
* isMaterialChosen: {
* order: 1,
* useRule: () => {
*
* }
* }
* });
* ```
*/
export function createWorkflow<Steps extends Workflow<Steps>['rules']>(rules: Steps): Workflow<Steps> {
/**
* `isComplete` rule reserved to check overall workflow completion.
*/
if ('isComplete' in rules) {
throw new Error('`isComplete` is a reserved rule');
}
/**
* `activeStep` rule reserved to check the current step in ordered workflows.
*/
if ('activeStep' in rules) {
throw new Error('`activeStep` is a reserved rule');
}
/**
* Sorted by `rule.order`, mapped to step's name (ex: `isSomethingComplete`).
* Rules with an order will always appear before unordered steps.
*/
const process = (Object.entries(rules) as [string, WorkflowRule][])
.sort(([, ruleA], [, ruleB]) => {
if (ruleA.order === undefined && ruleB.order === undefined) {
return 0;
}
if (ruleA.order === undefined) {
return 1;
}
if (ruleB.order === undefined) {
return -1;
}
if (ruleA.order < ruleB.order) {
return -1;
}
if (ruleA.order > ruleB.order) {
return 1;
}
return 0;
})
.map(([rule]) => rule) as (keyof Steps)[];
return Object.seal({
process,
rules,
});
}
/**
* #### Custom React Hook.
* Used to organize code into groups of related hooks;
* have your component depend on a single workflow instead of its potentially many dependent hooks.
*
* #### Example
* ```ts
* function ExampleComponent() {
* const workflow = useWorkflow(exampleWorkflow);
*
* if (workflow.isItemDesignerAssigned) {
* return React.createElement('h1', undefined, 'Item designer is assigned');
* }
*
* if (workflow.isMaterialChosen) {
* return React.createElement('h1', undefined, 'Material is chosen');
* }
* }
* ```
*/
export function useWorkflow<Steps extends Workflow<Steps>['rules']>(workflow: Workflow<Steps>): WorkflowStatus<Steps> {
/**
* Reduce sorted rule names into WorkflowStatus<Steps>.
* During reduction, all hooks used.
*/
return workflow.process.reduce(
(status, step, index) => {
const rule = workflow.rules[step];
const isRuleComplete = rule.useRule();
/**
* If previous and current step marked complete,
* set `status.aciveStep` to current index.
* After last complete step, update will not occur.
*/
if (status[workflow.process[index - 1]] && isRuleComplete) {
status.activeStep = index;
}
return Object.assign(status, {
isComplete: status.isComplete && isRuleComplete,
[step]: isRuleComplete,
});
},
{ activeStep: 0, isComplete: true } as WorkflowStatus<Steps>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment