Full example of modeling problems with data and functions using JavaScript / React.
yarn install
yarn start
/**
* Modeling a step-wise form with data
*
* NOTE: The important thing is to be able to
* describe any requirement with the chosen data model
*
* NOTE: Another important thing is conventional interfaces
* and impedance matching. Data should just flow through the
* functions without needing to conform to many different interfaces
*/
/**
* Models
*/
const form = {
title: 'My super cool stepwise form',
steps: [],
activeStep: 0,
}
const step = {
type: 'normal|branch',
title: 'This is step X',
fields: [],
flags: {
isVisible: false,
},
}
const field = {
type: 'input|date|list|multi|checkbox',
id: 'firstname',
name: 'First Name | Arrival Date | Colors | ....',
options: [],
value: [] || '' || true|false,
flags: {
isRequired: true,
},
}
/**
* Update functions / operations (reducers)
*/
function updateFieldValue({ step, field, value }, form) {
//... (can be conditional based on the step type)
}
function progressToNextStep(form) {
//... (calculates and updates the state to progress to the next step)
}
/**
* Selectors to get what you want from the data
*/
function getActiveStep(form) {
//...
}
function getFormOutput(form) {
//...
}
/**
* Tests that ask questions about the data
*/
function isVisible(step) {
//...
}
function isValid(field) {
//...
}
function isOptional(field) {
//...
}
/**
* Component selectors / mappers
*/
function getFieldComponent(type) {
const fieldComponents = {
'text-input': TextInputField,
'checkbox': CheckBoxField,
'date': DatePickerField,
'multi': MultiSelectField,
'default': NullComponent,
};
return fieldComponents[type] || fieldComponents.default;
}
function getStepContainer(type) {
const stepContainers = {
'normal': NormalStepContainer,
'branch': BranchStepContainer,
'default': NullComponent,
};
return stepContainers[type] || stepContainers.default;
}
/**
* Field Component (interface)
*/
const FieldComponent = class extends React.Component {
constructor(props) {
//...
},
render() {
const { ... } = this.props;
//...
}
}
/**
* Normal Step Container (React component interface)
*/
const NormalStepContainer = class extends React.Component {
constructor(props) {
//...
},
render() {
const { fields } = this.props;
const fieldList = fields
.map((field) => {
const FieldComponent = getFieldComponent(field.type);
return <FieldComponent {...field} />
});
return (
<div>
{fieldList}
</div>
)
}
}
/**
* Branch Step Container (React component interface)
*/
const BranchStepContainer = class extends React.Component {
constructor(props) {
//...
},
render() {
/**
* Get all the available options and the index
* specifying the one selected right now
*/
const { selectedOption, options } = this.props;
/**
* Get the field for the selected option
*/
const { fields } = options[selectedOption];
/**
* Map field data into React-components
*/
const fieldList = fields
.map((field) => {
const FieldComponent = getFieldComponent(field.type);
return <FieldComponent {...field} />
});
return (
<div>
<StepOptions options={options} />
{fieldList}
</div>
)
}
}
/**
* Options Container (React component interface)
*/
const StepOptions = class extends React.Component {
constructor(props) {
//..
}
render() {
const { options } = this.props;
return (
//... radio button select or something
);
}
}
/**
* Form Container (React component interface)
*/
const Form = class extends React.Component {
constructor(props) {
//...
}
render() {
const { steps } = this.props;
const stepList = steps
.map((step) => {
const StepContainer = getStepContainer(step.type);
return <StepContainer {...step} />
});
return (
<div>
{stepList}
</div>
)
}
}
/**
* App Container (React component interface)
*/
const App = class extends React.Component {
constructor() {
//...
}
render() {
const { form } = this.props;
return (
<div>
<h1>Amazing Title</h1>
<Form {...form} />
</div>
)
}
}
/**
* Rendering app with data as props
*/
ReactDOM.render(<App form={form} />, 'root');
/**
* You can now use data as a declarative interface for rendering
* a multi-step form with different types of fields
*/
/**
* What about handlers and interacting with the form?
* TODO: Step 1 - React State (reducer style)
* TODO: Step 2 - Redux + connecting
*/
/**
* Example state modeling a stepwise form
*/
const initialState = {
title: 'My super cool breakfast ordering form wizard',
activeStep: 0,
steps: [{
type: 'normal',
title: 'Step 1 - Who are you?',
fields: [{
type: 'text',
id: 'firstname',
name: 'firstname',
label: 'First Name:',
placeholder: 'John',
value: '',
}, {
type: 'text',
id: 'lastname',
name: 'lastname',
label: 'Last Name:',
placeholder: 'Doe',
value: '',
}],
}, {
type: 'normal',
title: 'Step 2 - What would you like for your breakfast?',
fields: [{
type: 'checkbox',
id: 'coffee',
name: 'coffee',
label: 'I want coffee:',
value: false,
}, {
type: 'checkbox',
id: 'bagels',
name: 'bagels',
label: 'I want bagels:',
value: false,
}, {
type: 'checkbox',
id: 'fruit',
name: 'fruit',
label: 'I want fruit:',
value: false,
}],
}, {
type: 'branch',
title: 'Step 3 - How would you like to pay?',
selectedOption: 0,
options: [{
id: 'visa',
label: 'VISA / MasterCard',
fields: [{
type: 'text',
id: 'cardnumber',
name: 'cardnumber',
label: 'Card number:',
placeholder: '1234-1234-1234-1234',
value: '',
}, {
type: 'text',
id: 'cvc',
name: 'cvc',
label: 'CVC security number:',
placeholder: '123',
value: '',
}, {
type: 'select',
id: 'expiry',
name: 'expiry',
options: ['2017', '2018', '2019', '2020', '2021'],
label: 'Expiry date:',
value: '',
}],
}, {
id: 'paypal',
label: 'PayPal',
fields: [{
type: 'text',
id: 'email',
name: 'email',
label: 'PayPal email:',
placeholder: '[email protected]',
value: '',
}],
}],
}],
};
/**
* The important question - can you describe any situation necessary with this data model?
* - data / selectors
* - operations
*
* NOTE: If yes - then it is trivial to visualize (with React), as you only need to hook it up
* to components that render based on props and attach event handlers for performing operations.
*
* NOTE: If no - back to the drawing board. There is no way you will be able to create something
* cohesive if you aren't able to describe it with the data model.
*
* NOTE: How you choose to visualize the state is completely up to you
* - Can show all steps at once as an accordion
* - Can show just a single step at the time (why not let the use choose?)
* - It's just a matter of mapping the data into UI the way you want
*/
/**
* Further: normalized vs. denormalized data (nested embedded vs. flat referenced)
*/
/**
* Step 1: Create a data model that can describe any state your problem can be in
* Step 2: Create a UI where you describe what the application looks like for any possible state
*/
/**
* NOTE: Choose duplication over the wrong abstraction
*
* Example:
*
* You could have merged NormalStepContainer and BranchStepContainer
* and added some conditional logic, but that will make the component
* a lot more complex and coupled. What if you end up having a lot of
* different step types? It is better to have separate versions,
* even if they might appear similar in the beginning.
*/
/**
* What about loading async values for a field (f.ex a selectfield)
*
* NOTE: You could specify a source and use something like react-select
* or create a component that will asynchronously fetch the options that
* you can choose from, which when chosen will synchronously update the state
*/
/**
* Validation
*/
function getValidator(type) {
/**
* Here you could build specific validator functions
* using an existing validation library (preferrably declarative, like joi)
*/
const validators = {
'email': (options) => (email) => /* ... */,
'text': (options) => (text) => /* ... */,
default: (options) => () => true,
};
return validators[type] || validators.default;
}
const initialState = {
title: 'My super cool breakfast ordering form wizard',
activeStep: 0,
steps: [{
type: 'normal',
title: 'Please give us your money',
fields: [{
id: 'paypal',
label: 'PayPal',
fields: [{
type: 'text',
id: 'email',
name: 'email',
label: 'PayPal email:',
placeholder: '[email protected]',
value: '',
flags: {
isRequired: true
},
validation: [{
type: 'email'
}],
}],
}],
}],
};
/**
* Next step: being able to interact with the form to update field values
*/
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_FIELD_VALUE':
return state;
default:
return state;
}
}
function updateField({ step, id, value }) {
return {
type: 'UPDATE_FIELD_VALUE',
step,
id,
value,
};
}
/**
* Next step: being able to submit the form
*/
function getFieldsSelector(type) {
const fieldsSelectors = {
normal: ({ fields }) => {
return fields.map(({ id, name, value }) => ({ id, name, value });
},
branch: ({ selectedOption, options }) => {
const { fields } = options[selectedOption];
return fields.map(({ id, name, value }) => ({ id, name, value }));
},
default: () => [],
};
return fieldsSelectors[type] || fieldsSelectors.default;
}
function getFormOutput(form) {
const { steps } = form;
return steps.map((step) => {
const getFields = getFieldsSelector(step.type);
return getFields(step);
})
.reduce((result, fields) => result.concat(fields), []);
}
/**
* Next step: being able to connect to async data sources
*/