An interface & data model for defining user-created forms, and accepting form submissions.
The following example outlines creating a form, adding fields to that form, and setting conditions between fields:
const form = new Form({
id: 'legit-form-from-ur-bank',
title: 'Super Legit Form',
description: 'This is a legit and not suspicious form',
});
const cardNumberField = new TextField({
name: 'credit_card',
format: 'text',
label: 'Credit Card Number',
maxLength: 16,
required: true,
});
const passportPhoto = new FileField({
name: 'passport_photo',
label: 'Photo of your Passport',
validMimeTypes: ['image/jpeg', 'image/png'],
validExtensions: ['.jpg', '.png'],
required: true,
});
const email = new TextField({
name: 'email',
label: 'Your Email',
format: 'email',
});
const subscribeNewsletterCheckbox = new BooleanField({
name: 'subscribe_newsletter',
label: 'Subscribe to our newsletter?',
defaultValue: true,
});
// Only show the checkbox to subscribe to our newsletter if the user provided
// their email.
subscribeNewsletterCheckbox.setLinkedFieldCondition(email, {
hasValue: true,
});
const emailCadence = new SelectField({
name: 'email_cadence',
label: 'How often can we send you emails?',
});
const everyDayCadence = emailCadence.addChoice(
new SelectFieldChoice({
id: 'every_day',
label: 'Every single day',
})
);
emailCadence.setDefaultChoice(everyDayCadence);
// Only show the cadence selector if the user answered yes on the checkbox:
emailCadence.setLinkedFieldCondition(subscribeNewsletterCheckbox, {
matchValue: true,
});
form.addField(cardNumberField);
form.addField(passportPhoto);
form.addField(email);
form.addField(subscribeNewsletterCheckbox);
form.addField(emailCadence);
This data model is based on a few core goals:
- Forms can have multiple fields, of different types
- Form fields can have complex user-defined properties, and it's easy to tweak, version and extend these properties as well as the underlying field types
- Make compromises and pragmatic decisions where the ideal solution isn't clear yet
- Form responses can be tracked
- The contents of form submissions can be traced back and enriched based on the underlying field type, and user-defined properties
- The contents of form submissions are resistant to changes on the original form, including fields being deleted
Note: relevant time-stamp fields are omitted for brevity, e.g createdAt, updatedAt, archivedAt
Form:
id ID
title string -- the title for this Form
description? string -- an optional longer description for this Form
archived boolean -- is this Form archived, or is it accepting new submissions?
Field:
id ID
fieldPropertiesId ID -- ID for the related <FieldType>FieldProperties
formId ID -- ID for the Form this field belongs to
name string -- name for this Field; may be updated, and must be unique within the Form
label string -- human-readable label for this Field
description? string -- an optional longer description for this Field
archived boolean -- is this Field archived, because it was soft-deleted?
type FieldType -- an enum value indicating the type of field (see FieldType)
linkedFieldId? ID -- ID for the conditioning Field
hasValue? boolean -- field is visible when conditioning field has any value
matchValueStr? string -- field is visible when string value matches
matchValueBool? boolean -- field is visible when boolean value matches
matchValueInt? number -- field is visible when number value matches
ENUM FieldType: => text | boolean | select | file | ...
TextFieldProperties:
id ID
fieldId ID -- ID for the related Field
format TextFormat -- the format validation rule for this text field (see TextFormat)
minLength? number -- optional minimum length for this text field
maxLength? number -- optional maximum length for this text field
placeholder? string -- an optional placeholder value for this text field
defaultValue? string -- an optional default value for this text field
ENUM TextFormat: => text | text-box | email | url ...
BooleanFieldProperties:
id ID
fieldId ID -- ID for the related Field
defaultValue? boolean -- an optional default value for this text field
SelectFieldProperties:
id ID
fieldId ID -- ID for the related Field
defaultChoiceId? ID -- an optional ID for the default SelectFieldChoice for this Field
SelectFieldChoice:
id ID
fieldPropertiesID ID -- ID for the related SelectFieldProperties
label string -- human-readable label for this selection choice
archived boolean -- is this SelectFieldChoice archived, because it was soft-deleted?
FileFieldProperties:
id ID
fieldId ID -- ID for the related Field
validMimeTypes? string[] -- an optional list of mime types to match this file field against
validExtensions? string[] -- an optional list of file extensions to match this file field against
maxSizeBytes? number -- an optional maximum size in bytes for uploaded files
FormResponse:
id ID
formId ID -- ID for the Form this FormResponse relates to
submitterId? ID -- optional ID of the user that submitted this FormResponse
TextFieldResponse:
id ID
formResponseId ID -- ID for the related FormResponse
fieldId ID -- ID for the related Field
value string -- text content for this response, on this field
BooleanFieldResponse:
id ID
formResponseId ID -- ID for the related FormResponse
fieldId ID -- ID for the related Field
value boolean -- boolean value for this response, on this field
SelectFieldResponse:
id ID
formResponseId ID -- ID for the related FormResponse
fieldId ID -- ID for the related Field
selectChoiceId ID -- ID for the selected SelectFieldChoice
FileFieldResponse:
id ID
formResponseId ID -- ID for the related FormResponse
fieldId ID -- ID for the related Field
uri string -- URI to locate the uploaded file (e.g s3 URI)
fileName string -- original file name for the uploaded file
fileSize number -- file size for the uploaded file
mimeType string -- mime type for the uploaded file
Fields are represented by two entities: a field container entity, and a set of properties for the type of field.
The field container includes shared field information, such as a label, an optional description, as well as things like if the field was archived.
The per-field-type properties entity allows fields to include complex, type safe, native properties that apply to that specific field and type only.
For example, the FileFieldProperties entity accepts user-defined options for valid mime types, allowed file extensions, and user-defined maximum file size:
FileFieldProperties:
...
validMimeTypes? string[] -- an optional list of mime types to match this file field against
validExtensions? string[] -- an optional list of file extensions to match this file field against
maxSizeBytes? number -- an optional maximum size in bytes for uploaded files
Conditional fields are represented by a set of normalized properties within the Field
entity:
Field:
...
linkedFieldId? ID -- ID for the conditioning Field
hasValue? boolean -- field is visible when conditioning field has any value
matchValueStr? string -- field is visible when string value matches
matchValueBool? boolean -- field is visible when boolean value matches
matchValueInt? number -- field is visible when number value matches
This approach is a least-effort compromise for handling field relationships and, more specifically, field visibility based on a linked field's value.
We use the field's type to match against an appropriate scalar type property. For example, we can
map against a boolean field type using matchValueBool
, and against a text field with matchValueStr
.
- Low effort, pragmatic
- Normalized, likely performant for most cases, and benefits from native data types
- Allows multiple fields to have a conditional relationship with the same field
- Easy to track the relationship between conditional values and their conditioners
- Easy to manage value matching for most field types
- Easy to migrate to a more robust approach later on
- Column layout feels unnatural, and requires additional steps to get a 'real' value for the match value
- Deleting a field requires severing the relationship by updating linking fields
- Somewhat breaks responsibility boundaries for the Field entity
- A field can only be conditioned by a single other field
Tracking relationships as first-class entity:
This approach may be the natural progression of the proposed solution. We can use separate entities to map the conditional relationship between fields, potentially for each field type, or field value scalar type.
Tracking conditional relationships with entities for each field type makes it possible to setup interesting scenarios, for example:
- A field that only becomes available if a user uploaded an image with certain dimensions
- A form that accepts youtube or vimeo links, and has different flows for either scenario
This wasn't the chosen approach since it doesn't fit a clear project need at the moment, and introduces considerable complexity/boilerplate.
Sub-object field for match values:
A sub-object field (e.g JSONB) arguably resolves most of the column "ickyness" around match values, but potentially introduces type safety issues (which can be solved with, for example, JSON schema).
Tracking match values on linked field
Inverting the relationship so the conditional field's match value is tracked by the linked field instead solves the type issues, since we can benefit from the individual field type tables & columns. This however introduces a weird relationship between entities, and prevents multiple fields from being conditioned by the same field.
Responses to a form are represented by a first-class response entity, which holds metadata on the form submission (e.g who, when), as well as acting as a container for the responses to the individual fields in a form.
The container approach also makes it easy to naturally handle multiple submissions by the same user, as well as across different versions of the same form.
The contents of each field in the user's submission are individually tracked in different entities depending on the field's type. This adds a complex layer of indirection, but allows us to naturally map and enrich this content.
For example, the details on a file uploaded by a user can be tracked using a FileFieldResponse
, including the original file name, its mime type, and where to find it after it was uploaded.
FileFieldResponse:
id ID
formResponseId ID -- ID for the related FormResponse
fieldId ID -- ID for the related Field
uri string -- URI to locate the uploaded file (e.g s3 URI)
fileName string -- original file name for the uploaded file
fileSize number -- file size for the uploaded file
mimeType string -- mime type for the uploaded file
Additionally, the individual field response object allows us to handle submissions to since-removed form fields appropriately, in conjunction with a field's archived
property.
- Ordering fields is not supported in the current data model. Could be implemented through a sort weight property on Field or similar approach.
- Conditional fields are implemented using a compromise approach, with a path for migration if necessary.
- Extracting
TextField
into discrete types forurl
,email
, etc could be interesting, e.g:- Gather, cache, and display rich metadata on URLs
- Enrich and provide quick contextual actions for email addresses
- Using a single column for
text
andtext-box
responses means using an underlying data type that isn't ideal for both - Form fields can be safely removed and added after submissions have been received, however, there's no native versioning support. We can use the same container entity approach as
FormResponse
to support form versioning. - Select field choices do not allow the user to define a value distinct from the label. Is there a need for this option?
- Not responding on non-required fields does not produce any trace other than the absence of a response on that field. Is that sufficient, or is there a valid use-case for more-explicitly tracking skipped fields?