Our forms are represented internally as recursive JSON objects which we call templates. The EditForm page reads a template and transforms it into a fill-out-able form. The form builder, then, is just a very visual, glorified JSON builder; its only job is to create that JSON object.
Here's the shape of a template:
type Template = Gadgetinterface Gadget {/////////// Keys that only exist on Layout Gadgets://///////// A list of other gadgets to be rendered in your container. These will be passed to you, pre-built, in your View and Edit functions (see "Gadget Definition")children?: [Gadget]/////////// Keys that only exist on Data Gadgets://///////// Data is stored on a document keyed by this. The end user cannot change thisformKey?: string// If true, the forms service will scrub this data before returning it to the clientsecret?: boolean// Whether or not this field is required to be filled out for submissionrequired?: boolean// This is a list of validations to apply to this gadget. For example, giving a text field a regex check or min/max for a number. Details about these validations can be found in https://github.com/kualibuild/builder-ui/blob/master/app/src/formbot/validations/index.jsxvalidations?: {[key]: {enabled: booleanvalue: V}}// If enabled, this name will be used to identify this gadget in the document list, gadget dropdowns, and anywhere else in the UIcustomName?: {enabled: booleanvalue?: string}// If enabled AND if you hit the graphQL api with the right query, the document(s) you receieve back from our API will have this gadget's data stored under this key instead of the formKeycustomFormKey?: {enabled: booleanvalue?: string}/////////// Keys that can exist on all Gadgets://///////// Every gadget has a unique, unchangeable idid: string// This is a string matching any registered gadget type. i.e. "Section" or "Text"type: string// The label is optional for layout gadgets, required for data gadgetslabel?: string// Gives the user more clarification about why this field is importantdescription?: {enabled: booleandisplayAsPopover?: booleanvalue?: string}// All the custom configuration specific to a gadget is stored here. You can find more details on this in "Gadget Definition"details?: GadgetDetails// Turn this on if you'd like this gadget to be shown/hidden based on other data in the formconditionalVisibility?: {enabled: boolean// This is the set of rules we'll evaluate to determine if your gadget should show or notvalue?: {// choose whether all of the rules must pass to show your gadget, or if only one needs to passtype: 'any' | 'all'parts: [{// the part of the form you depend on. Note this key is prefixed with `data.`, `meta.`, or `integration.` which means you can depend on any part of the document; not just the things the user filled outformKey: string// The data here follows a format defined by the gadget identified by formKey on the line above. In other words, unlike GadgetDetails and GadgetValidations: this GadgetPdData probably won't be the one defined by this gadget type. For example, let's say this is a Section that should show up if some other Text gadget contains the value "test". While most everything else in this json relates to Section, this GadgetPdData would be the one defined in Textdata: GadgetPdData}]}}}
You'll notice that a lot of those keys are objects with enabled and value. This is to support the configuration UI. A config panel shows up on the right of your screen when you drag a gadget onto the form in the form builder. Many of the options in that panel are toggled off by default. Toggling them on sets enabled to true, allowing you to fill out value.
You'll also notice I didn't define what GadgetDetails, GadgetValidations, or GadgetPdData look like. Each of them is an object but their shape is different based on the Gadget type. I'll make note of how those are created below in "Gadget Definition".
The template defines how a form should be displayed and which controls should show up. When a user fills out that form they create a Document. While the JSON structure representing a template is very recursive, documents are relatively flat.
interface Document {// Yup; everything has an idid: string;// Identifies which form this document belongs toformContainerId: string;// Identifies which version of the form this document belongs toformId: string;// We don't do hard deletes. If this is true the document won't show up in listsdeleted: boolean;// Whether or not the document has been submitted. We allow saving an invalid or incomplete document while in a draft state. Once it makes it to published we expect the document to be 100% valid. Note: even in a draft state we do not allow saving a document wherein one of its gadgets has failed its validateShape check. See below for more info on validateShapestatus: 'draft' | 'published';// This is autogenerated on the server. It's a collection of all the text in data, meta, and integration and is used for full text searchkeywords: [string];// All of the data entered by the user. It's an object keyed by formKey with values defined by each Gadget Definition. This object must strictly match the document's template. We don't allow extra keys and we enforce that GadgetValue matches the expected shapedata: {[formKey]: GadgetValue;}// All of the data that our systems add to the document. If you want data from here to be usable in the form you must tie your data to a gadget type registered in formbotmeta: {// The user that created the document. Will be empty for anonymous formscreatedBy?: User;// The user that hit the submitted the document. Will be empty for anonymous forms. Today these users are always the samesubmittedBy?: User;// When the doc was created...createdAt: number;// ...aaaand when it was submittedsubmittedAt: number;// The user that last updated the document.lastModifiedBy: User;// ...and when it was last updated by that userlastModifiedAt: number;//appPublishVersion: number;// A human-readable number identifying this doc. We start with 0001 and increment from there. This counter is not shared across the system; each App starts at 0001serialNumber: string;// All the data needed to render our workflow tracker screen. It's a pretty expensive object to compute so we cache it here after workflow finishes running instead of creating it at query timesimulation: object;// Shorter summary of where the document is at in workflow. Generated at the same time as simulation but used primarily in the document listworkflowStatus: 'Draft' | 'In Progress' | 'Error' | 'Withdrawn' | 'Rejected' | 'Complete';// How long this document has been in workflow since being submitted. This number is viewable/sortable in the document list and is rendered in TimeAgo formatworkflowRuntime: number;// How long this document has been in workflow since the document was first created. This number is viewable/sortable in the document list and is rendered in TimeAgo formatworkflowTotalRuntime: number;// Timestamp of when the workflow for this document completed.workflowCompletedAt: date;// ???currentWorkflowSteps: [???];}// System integrations that run in workflow have the ability to return and store data back on the form. This is where that data gets storedintegration: {[wfStepId.integrationNodeId]: GadgetValue;}}
Again, you'll notice that I didn't define what GadgetValue looks like. That is also defined differently for every gadget type. You'll see it below.
The real power of formbot comes from Gadgets! As you'll see in the definition below, they do a lot. These definitions are typically stored in a manifest.jsx file.
// The following types are all treated as opaque types outside of this gadgets definition. You should feel comfortable updating these types as long as you update every part of the gadget definition that creates, updates, or consume them. If this gadget is being used in production, updating these types will require a data migration// This represents the shape of data that your gadget stores and manipulatestype GadgetValue = any;// This is the configuration you expose to the end user and use within your gadgettype GadgetDetails = any;// If your gadget supports being used in conditional visibility, then this type is the data needed to perform your visibility checktype GadgetPdData = any;// This data represents your filter for gadgets that support filtering on the document listtype FilterValue = any;interface GadgetDefinition {// Whether this is a layout gadget or a data gadgetlayout?: boolean;// Given a config and a value, return a human-readable representation of your value. This is used in view-mode forms, on the document list, and in some cases on the edit-mode form. This component, as well as the Edit component below, are both passed a slew of other props. You can see what those are here: https://github.com/kualibuild/builder-ui/blob/master/app/src/formbot/engine/formbot/gadget-renderer.jsxView: ({ details: GadgetDetails, value: GadgetValue, ...others }) => ReactElement;// Given a config and a value, render an edit mode for your value. This is used in edit-mode forms and in Static Formbot. The argument you pass to onChange is saved and becomes your current valueEdit?: ({ details: GadgetDetails, value: GadgetValue, onChange: Function, ...others }) => ReactElement;// This is how you ask your use for any required configuration for your gadget to work. This will be rendered at the top of your gadget's config panel. value is your full config object. You can update it by passing a full config object as the argument to onChange. Gadgets are part of Static Formbot and are document in the next sectionRequiredConfig?: ({ value: GadgetDetails, onChange: Function, Gadgets: SFBGadget, ...others }) => ReactElement;// Same as above but this config is considered optional. You must render each chunk of your optional configs in a ConfigBox to fit in with the current UI. Note that both RequiredConfig and OptionalConfig are passed the full config object, not just the pieces they care about. If you save just the pieces you care about for OptionalConfig, you could be erasing the data needed in RequiredConfig. Always save the full config objectOptionalConfig?: ({ value: GadgetDetails, onChange: Function, Gadgets: SFBGadgets, ...others }) => ReactElement;// An Assembler takes all the pieces of a form control (label, description, control, errors, etc) and assembles them together. By default your gadget will use the Basic Assembler. If you want to override that, put something hereAssembler?: ReactComponent;// This is just a different way to choose your Assembler. Use this instead if you just want to use one of the built-in assemblersgetAssembler?: (assemblers: object, gridded: boolean) => ReactComponent;// This is only used on the Form Config page. It describes how your gadget looks in the gadget-picker sidebarmeta: {// Whether or not you even want your gadget to show up in the sidebarhidden?: boolean;// What category should your gadget be displayed undercategory: 'Layout' | 'Basic' | 'Special' | 'Smart';// An svg identifying your gadgetIcon: ReactElement;// The human-readable label for your gadgetlabel: string;// If set, a help icon will be displayed next to your gadget. On hover, this text will be shownhelp?: string;// If set, a deprecation icon will be displayed next to your gadget. On hover, this text will be showndeprecated?: string;// If provided, this is invoked when your gadget is first placed on the form. The results will be splatted into your template. You can use it to set a default label, required, a partial config, etcinitialTemplate?: () => object;}// If you'd like to sort your data on a nested key you can specify that heresortSuffix?: string;// If you support filtering on the document list, this is where you'll set that all upfilters?: {// Here's where you define what your filter ui looks like. value is your current filter state and onChange is how you update it. You also get your complete template in case you need it.UI: ({ gadget: Gadget, value: FilterValue, onChange: Function }) => ReactElement;// We display a pill to the user so they know a filter is applied. The default one is pretty good but you can override it if you need to.Pill?: ({ label: string, filter: FilterValue, gadget: Gadget }) => ReactElement;// Because we give users the option to persist these filters to the server, we need the gadget to provide a way to serialize/deserialize their data. These two functions are that way. This one takes in your filter state and returns a serialized version...toGraphQL: (filter: FilterValue) => T;// ...and this one reverses that operationfromGraphQL: (raw: T) => FilterValue;};// If you want your gadget to be available as a conditional visibility input, this is where you implement thatprogressiveDisclosure?: {// When someone turns on conditional visibility in the config panel of a different gadget, and they pick your gadget from the provided list, this Component will then be rendered. It's your job to gather all the info you need to make conditional visibility work. value is your current state, onChange will update thatcomponent: ({ gadgets: ???, value: GadgetPdData, onChange: Function, details: GadgetDetails }) => ReactElement;// It's possible that a user didn't finish filling out the needed data for you to make a good call on conditional visibility. This function is where you can determine that. If the config is incomplete, return falseconfigIsValid: (config: GadgetPdData) => boolean;// If conditional visibility is turned on AND the data you need is all there, then we'll call this function to find out if we should or should not show the gadget in question. You'll get the value that was filled out in your gadget as well as the data filled out in the conditional visibility config. Your job is to return a true (yes, please show this gadget) or false (nope, gotta hide it). You are also passed the entire document as a 3rd arg but that one is rarely neededcheck: (value: GadgetValue, config: GadgetPdData, document: Document) => boolean;};// When a document is first created, we will loop over all the gadgets in its form and use this key to generate a blank document. We also use this when doing required checks: if the stored value exactly matches this defaultValue it is considered unfilled and therefore the required check will faildefaultValue: GadgetValue;// deprecated. I don't believe this is actually used anywhere so we should get rid of itsampleValue: GadgetValue;// This is a function that takes in our custom validation library (formbot/engine/validate) and generates code to validate whether this gadget conforms to the GadgetValue type or not. It's used mostly on the server to validate that we're saving good documents. It's also used when displaying subFields to prevent those gadgets from crashing if invalid data is providedvalidateShape: Function;// This defines the set of validations that a user can choose to apply to this gadget.validationOptions?: [{key: string;label: string;UI: ({ Gadgets: SFBGadgets, details: GadgetDetails }) => ReactElement;evaluate: (value: GadgetValue, inputs: V, gadgetDetails: GadgetDetails) => [string] | null;description?: string | ReactElement;}];// This is exactly the same as validations on the template. When set here, however, the validations will always apply and are not user configurable. I believe this is only used in the email gadget today to apply an email regex check on every email gadgetvalidations?: {[key]: {enabled: boolean;value: V;}};// This allows gadgets to expose other gadgets. Think about a UserTypeahead. It stores a user object which also has a schoolId and an email. We want users to be able to use those extra fields in workflow, conditional visibility, the document list, etc. By setting up subFields, users can now use a Text gadget for a UserTypeahead's schoolId or an Email gadget for that UserTypeahead's email. You can see what fields are exposed in the config panel under the `Add linked auto-filled gadgets` sectionsubFields?: ({ id: string, formKey: string, label: string, details: GadgetDetails }) => [{// The attributes passed in to this function are those of the parent gadget. The ones you're returning in this array should be those of any sub-gadget you want to expose from this oneid: string;type: string;formKey: string;label: string;details: GadgetDetails;}];}
Some gadgets are only used in the document list. Only View is required for those ones though they could also benefit from filters. For gadgets that appear on the form View, Edit, defaultValue, and meta are all required. Most keys are optional and adding them will opt you in to more functionality.
To further benefit from the effort expended to make gadgets, we've created a component called StaticFormbot. This component allows you to use gadgets to build forms in jsx code rather than building the form from a json template. Here's an example of what that looks like:
const [state, setState] = React.useState({})<StaticFormbot value={state} onChange={setState}>{Gadgets => (<Gadgets.Sectionlabel='Config'description='This is where you configure the thing'><Gadgets.Text label="Placeholder" configKey='placeholder' /><Gadgets.Number label="Amount" configKey='amount' /></Gadgets.Section>)}</StaticFormbot>
We bind the value/onChange data handlers once and they get automatically set for all the gadgets you render. You are only required to set a configKey on each data gadget so that we know where to store that data. Many of our gadget configs, validation UIs, and progressive disclosure UIs use static formbot to simplify their code. When you see Gadgets of type SFBGadgets being passed in to the various components up above, this is what those are referring to.
You can pass context into Formbot.Edit/View and it'll make its way into each individual Gadget's Edit/View component. This was added before React's Context was officially supported and recommended. We should prefer using React.createContext + React.useContext over adding more keys to our context object. That way each gadget can opt in to the context they want. The way it's set up right now can lead to some pretty serious performance problems and should be deprecated.
Conditional Visibility is the ability for the form to show or hide gadgets based on the values entered into other gadgets. For example, you might use a Radios gadget to ask the user if their favorite food is Cordon Bleu, ___, or other. And if they pick other, then and only then you want to show them a Text gadget to find out what other food is their favorite.
In the inital versions of formbot we called this feature Progressive Disclosure. We have since updated that name to Conditional Visibility as it user-tested better. You'll probably still see remnants in the code referring to pd or ProgDisc or Progressive Disclosure. That's what it's talking about.
As you look through Formbot code, you'll probably run across these 3 terms quite often. Here's what they mean:
{ template: {}, metaFields: [], integrationFields: [], trashed: [] }
template is a recursive object structure. Note: its formkeys are not prefixedmetaFields is a list of gadgets with meta.-prefixed formkeysintegrationFields is a list of gadgets with integration.-prefixed formkeystrashed is deprecated. Move along.{ data, meta, integration }
data is generated as you fill out the form. meta is generated by the server and contains things like submittedBy and workflowSimulation. integration will contain data generated by integrations in workflow.
[gadget]
Each of these gadgets' formkeys are prefixed with one of data., meta., or integration..