Home > Guides > How To: Define a Custom Input Type

How To: Define a Custom Input Type

The wq framework provides a useful assortment of default input types, but individual project needs often require reconfiguring and extending the defaults with custom versions. For example, you may want to override the widget used for a field, or hide a group of inputs unless an earlier input has a specific value. (This is commonly referred to as skip logic or the “relevant” setting in XLSForm). wq does not currently support the “relevant” setting out of the box, but it is easy to define a custom input that does the same thing.

  • Initial Setup
  • Step 1: Update Model Definition
  • Step 2: Configure wq.db Serializer
  • Step 3: Implement Wrapper Component
  • Step 4: Implement Custom Component

Initial Setup

For this how-to guide, we’ll assume a simple project with a single “survey” app. You can download the example XLSForm here:

Download survey.csv

See the getting started guide for more details about initial project setup.

wq create myproject --without-npm
cd myproject/db/
wq addform path/to/survey.csv

This should result in the following app layout:

db/survey/models.py

from django.db import models


class Survey(models.Model):
    color = models.CharField(
        choices=(
            ("red", "Red"),
            ("green", "Green"),
            ("blue", "Blue"),
            ("other", "Other"),
        ),
        max_length=5,
        null=True,
        blank=True,
        verbose_name="Pick a Color",
        help_text="Choose one of the listed colors, or select Other to pick your own.",
    )
    other_color = models.TextField(
        null=True,
        blank=True,
        verbose_name="Other Color",
        help_text="Enter the name of your custom color.",
    )

    class Meta:
        verbose_name = "survey"
        verbose_name_plural = "surveys"

db/survey/rest.py

from wq.db import rest
from .models import Survey


rest.router.register_model(
    Survey,
    fields="__all__",
)

Demo 1

After running ./deploy.sh, you should have an app with essentially the following configuration:

// app/js/data/config.js
const config = {
    "pages": {
        "survey": {
            "name": "survey",
            "url": "surveys",
            "list": true,
            "form": [
                {
                    "name": "color",
                    "label": "Pick a Color",
                    "hint": "Choose one of the listed colors or select Other to pick your own.",
                    "type": "select one",
                    "choices": [
                        {
                            "name": "red",
                            "label": "Red"
                        },
                        {
                            "name": "green",
                            "label": "Green"
                        },
                        {
                            "name": "blue",
                            "label": "Blue"
                        },
                        {
                            "name": "other",
                            "label": "Other"
                        }
                    ]
                },
                {
                    "name": "other_color",
                    "label": "Other Color",
                    "hint": "Enter the name of your custom color.",
                    "type": "text"
                }
            ],
            "verbose_name": "survey",
            "verbose_name_plural": "surveys"
        }
    }
};

// app/js/myproject.js
import wq from './wq.js';

wq.init(config).then(...);

// navigate to /surveys/new

Step 1: Update Model Definition

As can be seen from the example above, each input’s choices, label, and hint are derived directly from the Django model definition. Thus, it is not necessary to configure a serializer or implement a custom component to override these attributes. Instead, just update the setting in survey/models.py, then run ./deploy.sh again to regenerate app/js/data/config.js.

Try changing the "choices" in the example data/config.js above and watch how the form updates. In an actual project, data/config.js should not be modified directly, as it will be overridden during the next deploy.

Step 2: Configure wq.db Serializer

So far in this guide, we have relied on wq.db’s default ModelSerializer class, which automatically generates a form configuration from the Django model fields. It is possible to override the serializer for a model to further customize the generated configuration. (Though the underlying mechanism is different, this is directly analagous to how Django’s ModelForm generates a default form field for each model field, but can be customized with field_classes.)

To configure the serializer, create db/survey/serializers.py and define a class that extends ModelSerializer. You can then customize the field appearance by setting the style attribute on the Serializer field. (wq.db will search for a wq_config key and ignore the rest of the style.) For example, you might want to always render a multiple choice field as <Select/>, regardless of how many choices it has.

db/survey/serializers.py

from wq.db.rest.serializers import ModelSerializer
from rest_framework import serializers
from .models import Survey


class SurveySerializer(ModelSerializer):
    color = serializers.ChoiceField(
        style={"wq_config": {'control': {'appearance': 'select'}}}
    )
    class Meta:
        model = Survey
        fields = '__all__'

Alternatively, you can define Meta.wq_field_config to avoid having to manually redeclare the serializer field.

db/survey/serializers.py (with wq_field_config)

from wq.db.rest.serializers import ModelSerializer
from rest_framework import serializers
from .models import Survey


class SurveySerializer(ModelSerializer): 
    class Meta:
        model = Survey
        fields = '__all__'
        wq_field_config = {
            'color': {'control': {'appearance': 'select'}}
        }

Then, update the wq.db model registration:

db/survey/rest.py (with serializer)

from .models import Survey
from .serializers import SurveySerializer


rest.router.register_model(
    Survey,
    serializer=SurveySerializer,
)

The custom keys will be merged with the default field configuration to generate data/config.js. Each field’s configuration is passed as props to <AutoInput />, which selects and renders the actual input component.

Demo 2

Registering SurveySerializer should result in the following app configuration:

// app/js/data/config.js
const config = {
    "pages": {
        "survey": {
            "name": "survey",
            "url": "surveys",
            "list": true,
            "form": [
                {
                    "name": "color",
                    "label": "Pick a Color",
                    "hint": "Choose one of the listed colors or select Other to pick your own.",
                    "type": "select one",
                    "control": {
                        // try "toggle" or "radio"
                        "appearance": "select"
                    },
                    "choices": [
                        {
                            "name": "red",
                            "label": "Red"
                        },
                        {
                            "name": "green",
                            "label": "Green"
                        },
                        {
                            "name": "blue",
                            "label": "Blue"
                        },
                        {
                            "name": "other",
                            "label": "Other"
                        }
                    ]
                },
                {
                    "name": "other_color",
                    "label": "Other Color",
                    "hint": "Enter the name of your custom color.",
                    "type": "text"
                }
            ],
            "verbose_name": "survey",
            "verbose_name_plural": "surveys"
        }
    }
};

// app/js/myproject.js
import wq from './wq.js';

wq.init(config).then(...);

// navigate to /surveys/new

Look for “appearance” in the configuration above and try changing it to a different value.

Step 3: Implement Wrapper Component

The “appearance” attribute can be any value, and is not restricted to the default input components. For example, you can set the appearance of the other_color field to 'other-input' to implement the hiding logic below.

db/survey/serializers.py (with wrapper input)

from wq.db.rest.serializers import ModelSerializer
from rest_framework import serializers
from .models import Survey


class SurveySerializer(ModelSerializer): 
    class Meta:
        model = Survey
        fields = '__all__'
        wq_field_config = {
            'color': {'control': {'appearance': 'select'}},
            'other_color': {'control': {'appearance': 'other-input'}},
        }

Custom input types should be implemented as React components, and registered with wq via its plugin API. In many cases, you can implement a custom input by importing the default equivalent from @wq/material and adding some wrapper logic. In more advanced cases, you may need to import directly from @material-ui/core or another third party library. In either case, you will typically need some helper functions from the formik library. @wq/material, formik, and (parts of) @material-ui/core are all exported by the default wq.js build provided by wq.app. This means you can generally use them whether you are using wq’s create-react-app template or the Django template without npm.

Note: When using wq create --without-npm, you will need a way to compile JSX to React.createElement() calls. You could use the online Babel converter, or use npm to install Rollup and Babel (but not necessarily all of create-react-app and wq’s npm dependencies). If you use Rollup, you may find @wq/rollup-plugin useful, as it will allow you to write plain npm imports and have them automatically translated to leverage exports from ./wq.js. The app/js/custom.js example in the demo below simulates the output of a Rollup build.

In this case, the goal is to have the “Other Color” input remain hidden until the "other" value is selected in the “Color” input. To do this, we can define OtherInput as a wrapper component that renders null unless the condition is met. Note that all of the customization happens in the OtherInput, which has direct access to the full form state via useFormikContext(). It is not necessary (or recommended) to attach an onChange handler directly to the “Color” input to control the display of OtherInput.

Demo 3

// app/js/custom.js
import { modules } from './wq.js';
const React = modules['react'];
const { useFormikContext, getIn } = modules['formik'];
const { Input } = modules['@wq/material'];

function OtherInput(props) {
    const { values } = useFormikContext(),
        color = getIn(values, props.name.replace('other_color', 'color'));
    if (color !== "other") {
        return null;
    }
    return <Input {...props} />;
}

const custom = {
    "inputs": { OtherInput }
}

// app/js/data/config.js
const config = {
    "pages": {
        "survey": {
            "name": "survey",
            "url": "surveys",
            "list": true,
            "form": [
                {
                    "name": "color",
                    "label": "Pick a Color",
                    "hint": "Choose one of the listed colors or select Other to pick your own.",
                    "type": "select one",
                    "control": {
                         "appearance": "select"
                    },
                    "choices": [
                        {
                            "name": "red",
                            "label": "Red"
                        },
                        {
                            "name": "green",
                            "label": "Green"
                        },
                        {
                            "name": "blue",
                            "label": "Blue"
                        },
                        {
                            "name": "other",
                            "label": "Other"
                        }
                    ]
                },
                {
                    "name": "other_color",
                    "label": "Other Color",
                    "hint": "Enter the name of your custom color.",
                    "type": "text",
                    "control": {
                         "appearance": "other-input"
                    },
                }
            ],
            "verbose_name": "survey",
            "verbose_name_plural": "surveys"
        }
    }
};

// app/js/myproject.js
import wq from './wq.js';
wq.use(custom);
wq.init(config).then(...);

// navigate to /surveys/new

If a field is configured to use an unregistered input type, an error message will be displayed instead of the input. You can see this in action by removing wq.use(custom); from the example above.

Note the use of getIn() in the example above. In this case, we know that the field name is color so it would be fine to directly access values.color. However, if the custom field is ever used in a nested form or fieldset, then the name of the field might be something more complicated like coloritems[0].color. Using getIn() with props.name.replace() helps ensure the field can be used across a variety of use cases.

Step 4: Implement Custom Component

The example above simply wrapped the existing <Input/> component with some additional hiding logic. But what if you want to implement a completely new component, not based on any of the existing ones? For example, the Color input could actually show each color in a pallete, rather than a list of choices.

Continuing from the example above:

db/survey/serializers.py (with custom input)

from wq.db.rest.serializers import ModelSerializer
from rest_framework import serializers
from .models import Survey


class SurveySerializer(ModelSerializer): 
    class Meta:
        model = Survey
        fields = '__all__'
        wq_field_config = {
            'color': {'control': {'appearance': 'color-input'}},
            'other_color': {'control': {'appearance': 'other-input'}},
        }

In general, you can easily integrate third party components and custom ones via formik’s useField() hook.

Demo 4

// app/js/custom.js
import { modules } from './wq.js';
const React = modules['react'];
const { useField, useFormikContext, getIn } = modules['formik'];
const { Input } = modules['@wq/material'];

function ColorChoice({ name, label, selected, onClick }) {
    return (
        <button
            type="button"
            onClick={onClick}
            title={label}
            style=
        >
            {name == 'other' ? '...' : ''}
        </button>
    );
}

function ColorInput({ name, choices }) {
    const [, { value }, { setValue }] = useField(name);
    return (
        <div style=>
            {choices.map((choice) => (
                <ColorChoice
                    name={choice.name}
                    label={choice.label}
                    selected={value === choice.name}
                    onClick={() => setValue(choice.name)}
                />
            ))}
        </div>
    );
}

function OtherInput(props) {
    const { values } = useFormikContext(),
        color = getIn(values, props.name.replace('other_color', 'color'));
    if (color !== 'other') {
        return null;
    }
    return <Input {...props} inputProps=type />;
}

const custom = {
    inputs: { ColorInput, OtherInput },
};

// app/js/data/config.js
const config = {
    pages: {
        survey: {
            name: 'survey',
            url: 'surveys',
            list: true,
            form: [
                {
                    name: 'color',
                    label: 'Pick a Color',
                    hint:
                        'Choose one of the listed colors or select Other to pick your own.',
                    type: 'select one',
                    control: {
                        appearance: 'color-input',
                    },
                    choices: [
                        {
                            name: 'red',
                            label: 'Red',
                        },
                        {
                            name: 'green',
                            label: 'Green',
                        },
                        {
                            name: 'blue',
                            label: 'Blue',
                        },
                        {
                            name: 'other',
                            label: 'Other',
                        },
                    ],
                },
                {
                    name: 'other_color',
                    label: 'Other Color',
                    hint: 'Pick your custom color.',
                    type: 'text',
                    control: {
                        appearance: 'other-input',
                    },
                },
            ],
            verbose_name: 'survey',
            verbose_name_plural: 'surveys',
        },
    },
};

// app/js/myproject.js
import wq from './wq.js';
wq.use(custom);
wq.init(config).then(...);

// navigate to /surveys/new

Note that <ColorInput/> uses useField(), while <OtherInput/> uses useFormikContext(). Both provide access to much of the same information, but useField() makes more sense when working with an independent field.

It also makes sense to change <OtherInput/> to be a fully custom color picker. In this case we can use the browser’s built-in <input type=color> via inputProps as shown in the example above.