It is a tool that helps design, create and manage form / survey / questionnaire through simple JSON configurations.
It provides:
- Data structure for form configurations and answers.
- Conditionally disabling/enabling questions based on choices made in another question.
- Answer validation mechanism.
- Instructions for rendering the UI based on current status of the form.
It does not provide:
- Any UI components, define your own UI configurations that suit your project needs and render the UI according to your own design system.
- Validators, define your own validators that suit your project needs.
npm install form-studio
import { Form } from 'form-studio';
or
const { Form } = require('form-studio');
Form configs is the definition of the form. It should be persisted somewhere (e.g. database) so that it can be reused later.
There are 3 types of items in a configs: Group
, Question
and Choice
.
Each of them has the following properties:
id
: An unique id to identify the itemorder
: Sort order of the item among it's parentdefaultDisabled
: To indicate that the item is disabled by defaultcustom
: Any values that help you determine on how to render the frontend UI or how to perform validation, it should contain minimal but useful information, e.g. title, placeholder
A group is a logical grouping of a set of questions.
A form needs at least 1 group.
Groups can also have sub-groups.
There are 3 types of questions: any
, choice
and choices
.
A question comes with an answer (could be undefined if it is unanswered) and an error (could be undefined if it is unanswered, unvalidated or passed validation).
any
questions accept any
value as an answer.
choice
questions accept a choice value as an answer.
choices
questions accept a list of choice values as an answer.
choice
and choices
questions need to have 1 or more choices.
You can also define the validators to be used by a question to validate its answer.
Choices are for choice
or choices
questions.
A choice comes with a value. Value of the choices will be the answer of the question.
A choice has the ability to disable/enable other groups/questions/choices when it's selected/unselected.
The following example consists of 1 group and 2 questions under it.
The second question is disabled by default. If 'yes' is selected for the first question, the second question will be enabled.
{
"groups": [
{
"questions": [
{
"id": "proceed",
"type": "choice",
"custom": {
"title": "Would you like to proceed?"
},
"choices": [
{
"id": "yes",
"custom": {
"title": "Yes"
}
},
{
"id": "no",
"custom": {
"title": "No"
}
}
],
},
{
"id": "name",
"defaultDisabled": true,
"enabledOnSelected": ["yes"],
"type": "any",
"custom": {
"type": "string",
"title": "What is you name?",
},
}
]
}
]
}
You can call validateConfigs
method to validate your configs.
Form.validateConfigs(configs);
form-studio
doesn't come with any predefined validator. You need to define your own validators according to your project needs.
A validator is a function that will be called when the answer of a question is updated, it throws error when validation fails.
Each question can be assigned with one or more validators to be used.
const validators = {
atLeast1: answer => {
if (answer.length < 1) {
throw new Error('Please select at least 1 option.');
}
},
notNull: answer => {
if (!answer) {
throw new Error('This question cannot be left unanswered.');
}
},
number: (answer, question) => {
const { min, max } = question.custom;
if (answer < min){
throw new Error('Please enter no less than ' + min + '.');
}
if (answer > max){
throw new Error('Please enter no greater than ' + max + '.');
}
}
};
A listener function that will be called when form is updated.
Form will be updated when answer is set, validation is triggered, etc.
Form updated listener is needed when the form is being used in frontend, so that you can trigger an UI rerender when form is updated.
const [renderInstructions, setRenderInstructions] = useState<RenderInstructions>();
const onFormUpdate = form => setRenderInstructions(form.getRenderInstructions());
const form = new Form(configs, { validators, onFormUpdate });
Render instructions can be get by calling getRenderInstructions
method.
It is a set of instructions that tell you how the form should look like.
Each item in the instructions comes with the following properties:
id
: An unique id to identify the itemdisabled
: Whether or not this item is disabled, you should handle it in the UI, e.g. hide or grey out disabled itemcustom
: The exact same values that you specified in the form configs
Questions also come with the following important properties that you will need to use to determine the UI:
type
:any
: render whatever UI that is required based on yourcustom
configs, e.g. ifcustom.inputType
isstring
, then a text input is renderedchoice
: render UI that allows user to select 1 option from a list of options, e.g. select, radio button groupchoices
: render UI that allows user to select multiple options from a list of options, e.g. check box group
currentAnswer
: current answer of the question, it is unvalidated and might not be valid, but you will still need to show them on UIvalidatedAnswer
: validated answervalidating
: whether or not the question is currently being validated, it could happen if the validator used is an aysnc function, you might want to show a spinner or some other indicator on UIerror
: error for question which failed validation
let form: Form;
export const SurveyPage = () => {
const [renderInstructions, setRenderInstructions] = useState<RenderInstructions>();
useEffect(() => {
form = new Form(configs, {
validators,
validate: false,
onFormUpdate: form => setRenderInstructions(form.getRenderInstructions())
});
}, []);
const renderQuestion = (question: QuestionRenderInstructions) => {
const { disabled, type, custom } = question;
if (disabled) {
return null;
}
if (type === 'any') {
return (
<>
{custom.inputType === 'string' && renderStringInput(question)}
</>
);
}
if (type === 'choice') {
return renderRadioGroup(question);
}
if (type === 'choices') {
return renderCheckBoxGroup(question);
}
};
const renderRadioGroup = (question: QuestionRenderInstructions) => {
const { id, choices, error, currentAnswer } = question;
return (
<RadioGroup
error={error}
value={currentAnswer}
onChange={e => form.setChoice(id, e.target.value)}>
{choices!.map(choice =>
<Radio
value={choice.value}
disabled={choice.disabled}>
{choice.custom.title}
</Radio>
)}
</RadioGroup>
);
};
const renderCheckBoxGroup = (question: QuestionRenderInstructions) => {
const { id, choices, error, currentAnswer } = question;
return (
<CheckBoxGroup
error={error}
value={currentAnswer}
onChange={answer => form.setChoices(id, answer as any[])}>
{choices!.map(choice =>
<CheckBox
value={choice.value}
disabled={choice.disabled}>
{choice.custom.title}
</CheckBox>
)}
</CheckBoxGroup>
);
};
const renderStringInput = (question: QuestionRenderInstructions) => {
const { id, custom, currentAnswer, error } = question;
return (
<TextInput
error={error}
maxLength={custom.maxLength as number}
value={currentAnswer}
onChange={e => form.setAny(id, e.target.value)} />
);
};
return renderInstructions?.questions.map(question => renderQuestion(question));
};
any
questions use setAnswer
or setAny
method to set answer.
choice
questions use setAnswer
, setChoice
or selectChoice
method to set answer.
choices
questions use setAnswer
, setChoices
or selectChoice
method to set answer.
form.setChoice('proceed', 'yes');
form.setAny('name', 'Jason');
onChange={e => form.setChoice(id, e.target.value)}
onChange={e => form.setAny(id, e.target.value)}
Use validate
method to trigger validation for the entire form.
Use isValidating
& isClean
methods to get the state of form validation.
E.g. you can disable a button if isValidating
is true
or isClean
is false
.
const save = () => {
if (form.isValidating() || !form.isClean()) {
alert('Please try again.')
}
}
<Button onClick={save}>Save</Button>
Use asyncValidate
method to get the final validated answers.
You can then store the answers to database or send it to backend via API.
const answers = await form.asyncValidate();
if (!answers) {
alert('There are some invalid answers.');
return;
}
await ... // Call API to send the answers to backend
If you are sending the answers from frontend to backend, backend can construct the form using the same configs, import the answers, and call asyncValidate
method again to revalidate the answers from frontend before you save them into database.
const answers = req.body;
form.importAnswers(answers);
const valid = await form.asyncValidate();
if (!valid) {
res.status(400);
res.json({ error: 'There are some invalid answers.' });
res.end();
return;
}
await ... // Save the answers to database
res.status(200);
res.end();
Use importAnswers
method to import answers to the entire form.
const answers = await ... // retrieve from API
form.importAnswers(answers);
const answers = await ... // retrieve from database
form.importAnswers(answers);
Use the following methods to clear current answers:
clear
clearGroup
clearAnswer
Use the following methods to reset answers to their default answers:
reset
resetGroup
resetAnswer