“Show this field only when that one says yes” is the feature every form builder ships and nobody specifies. It sounds like one boolean. It is three independent state machines, two implementations of the same grammar in two languages, and one browser behaviour that will silently break your form the first time you hide a required field.
This is the conditional logic in PaperPoint — sixteen operators, six actions, and the reasoning for the parts that are not obvious.
The grammar
A condition is a row. That is the whole design decision, and everything else follows from it.
class FieldCondition(TimeStampedModel):
field = FK(FormField, related_name="conditions") # target
source_field = FK(FormField, related_name="triggered_conditions") # trigger
operator = CharField(choices=OPERATORS)
value = CharField(max_length=500, blank=True)
action = CharField(choices=ACTIONS, default="show")
logic_operator = CharField(choices=LOGIC_OPERATORS, default="and")
position = PositiveIntegerField(default=0)
is_active = BooleanField(default=True)
Target, trigger, comparison, consequence. A customer building a form in a browser never types an expression; they pick from four dropdowns. That constraint — no expression language — is what makes the feature usable by someone who has never programmed, and it is also what forces the operator list to be finite and explicit:
OPERATORS = [
("equals", _("Equals")),
("not_equals", _("Not equals")),
("contains", _("Contains")),
("not_contains", _("Does not contain")),
("starts_with", _("Starts with")),
("ends_with", _("Ends with")),
("greater_than", _("Greater than")),
("less_than", _("Less than")),
("greater_or_equal", _("Greater or equal")),
("less_or_equal", _("Less or equal")),
("is_empty", _("Is empty")),
("is_not_empty", _("Is not empty")),
("in_list", _("In list")),
("not_in_list", _("Not in list")),
("is_checked", _("Is checked")),
("is_not_checked", _("Is not checked")),
]
Sixteen, and each pair is present in both directions. That is not symmetry for
its own sake: a customer who wants “hide when empty” should not have to express
it as “show when not empty” and then reason about the default state. Giving them
is_empty and is_not_empty costs one line and removes a class of
misconfiguration where the rule is right and the default is wrong.
is_checked / is_not_checked look redundant next to equals true. They are
not, and the reason is in the evaluator:
"is_checked": lambda: source_str in ("true", "1", "on", "yes"),
"is_not_checked": lambda: source_str in ("false", "0", "off", "no", ""),
A checkbox does not have one serialisation. It arrives as "on" from a raw HTML
form, true from JSON, "1" from a mobile client, and absent entirely when
unchecked. equals true is correct for exactly one of those. The operator exists
to hold that mess in one place instead of in every customer’s head.
Note the asymmetry in the last one: "" counts as not-checked. An absent
checkbox and an unchecked checkbox are the same fact, and the grammar has to say
so, because HTML will not.
Three axes, not one
Here is where most implementations go wrong.
ACTIONS = [
("show", _("Show field")),
("hide", _("Hide field")),
("enable", _("Enable field")),
("disable", _("Disable field")),
("require", _("Make required")),
("unrequire", _("Make optional")),
]
Six actions, but not six independent things — three axes with two directions each: visible/hidden, enabled/disabled, required/optional.
The temptation is to fold them together, because in the common case they move as one: a field that appears because you ticked a box is usually also the field you must fill in. Fold them and the model is simpler, the JSON is smaller, and the UI has one dropdown instead of two.
It is wrong, and the counterexamples are the ordinary cases:
- A field shown but not required. “Anything else we should know?” appears when you tick report a problem, and stays optional.
- A field required but not shown-by-this-rule. An IBAN that is always on the form and becomes mandatory only when the payout method is bank transfer. Its visibility is nobody’s business; only its obligation changes.
- A field visible but disabled. A computed total, or a value inherited from a previous step. Hiding it would be worse — the respondent needs to see it, they just cannot type into it. This is the case that folding the axes makes impossible to express at all.
So the evaluator carries three variables:
let shouldShow = hasShowCondition ? false : true;
let shouldEnable = true;
let shouldRequire = null;
Two of these lines deserve a paragraph each.
The default state is derived, not stored
shouldShow = hasShowCondition ? false : true.
There is no “hidden by default” column on FormField, and there should not be.
The default is derived from the set of rules attached to the field:
- a field carrying a
showrule is hidden until something shows it; - a field carrying only
hiderules is visible until something hides it.
Storing the default separately means storing the same fact twice. Two facts that
must agree eventually disagree — a customer deletes the last show condition and
the field stays hidden forever, with nothing in the interface explaining why. The
rule set is the specification; the initial state is a function of it.
The same derivation runs once before any input, so a form with conditional fields does not flash them all on first paint:
applyInitialState() {
Object.entries(this.conditions).forEach(([targetField, fieldConditions]) => {
const hasShowCondition = fieldConditions.some(c => c.action === 'show');
const hasHideCondition = fieldConditions.some(c => c.action === 'hide');
const wrapper = this.fieldWrappers[targetField];
if (!wrapper) return;
if (hasShowCondition && !hasHideCondition) {
this.setFieldVisibility(wrapper, false);
}
});
}
hasShowCondition && !hasHideCondition — a field carrying both kinds of rule is
left alone rather than guessed at. When the specification is ambiguous the code
declines to invent an answer, and the first real input resolves it two hundred
milliseconds later.
null is not false
shouldRequire = null is a three-state variable in a language that has booleans,
and the third state is the point: no rule spoke about this axis.
this.setFieldVisibility(wrapper, shouldShow);
this.setFieldEnabled(wrapper, shouldEnable);
if (shouldRequire !== null) {
this.setFieldRequired(wrapper, shouldRequire);
}
If shouldRequire defaulted to false, then every field with a show rule and
no require rule would be stripped of the required its designer set in the
field configuration — the conditional logic would quietly delete a constraint it
was never asked about. Defaulting to true is worse in the mirror direction.
The only correct default for an axis nobody mentioned is do not touch it, and
in JavaScript that has to be spelled null, because false is an answer.
Visibility and enablement do not need the same treatment: both have a genuine
default (visible, enabled) that the field configuration does not override.
Requiredness does have such an override, so it gets the third state. Consistency
would have been the wrong instinct here.
Hiding a required field breaks the browser
This is the bug. Every implementation hits it, usually in production, and the symptom is spectacular:
The form will not submit, and nothing on screen is wrong.
Chrome reports An invalid form control with name='x' is not focusable. The
respondent presses Send, nothing happens, no message appears, and there is
nothing to fix because the offending field is display: none. Constraint
validation applies to required controls whether or not they are visible; the
browser tries to focus the field to explain itself, cannot, and gives up
silently.
So visibility cannot be display: none alone. It has to manage the required
attribute — and it has to be able to put it back:
setFieldVisibility(wrapper, visible) {
if (visible) {
wrapper.style.display = '';
wrapper.classList.remove('d-none', 'condition-hidden');
} else {
wrapper.style.display = 'none';
wrapper.classList.add('d-none', 'condition-hidden');
}
const inputs = wrapper.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
if (visible) {
input.removeAttribute('data-condition-disabled');
if (input.dataset.wasRequired === 'true') {
input.required = true;
}
} else {
if (input.required) {
input.dataset.wasRequired = 'true';
input.required = false;
}
input.setAttribute('data-condition-disabled', 'true');
}
});
}
data-was-required is the whole trick. Clearing required is easy; the hard
part is that hiding is not final — the respondent unticks the box, the field
comes back, and it has to come back as it was. Without somewhere to record the
original state, showing a field again either leaves a mandatory field optional or
makes an optional field mandatory, depending on which mistake you prefer.
The flag lives on the DOM node rather than in a JavaScript map on purpose: the state belongs to the element, survives it being moved, and is visible in the inspector when someone is debugging a form they did not build.
The same grammar, twice
Every operator above exists in two languages. In Python:
evaluators = {
"equals": lambda: source_str == compare_value,
"contains": lambda: compare_value in source_str,
"starts_with": lambda: source_str.startswith(compare_value),
"greater_than": lambda: self._compare_numeric(source_value, float(self.value), ">"),
"in_list": lambda: source_str in [v.strip().lower() for v in self.value.split(",")],
"is_checked": lambda: source_str in ("true", "1", "on", "yes"),
...
}
And in JavaScript, as a switch over the same sixteen names, on the same
normalised strings — String(v).trim().toLowerCase() on both sides, so
"Yes", "yes" and " YES " compare equal in both languages, identically.
Duplication like this is usually a mistake. Here it is the design, and the two copies are not doing the same job:
The browser copy is presentation. It runs on every keystroke, debounced, and decides what the respondent sees. It must be instant; a round-trip per keystroke is not a form, it is a chat.
The Python copy is truth. It answers “was this field genuinely applicable when this answer was submitted?” — the question that matters when a submission arrives from a client that never ran the JavaScript at all: a mobile app, an offline queue flushing three days later, a script, or a browser with the console open.
That distinction has a consequence worth stating flatly, because it is the whole reason this article exists:
Conditional logic implemented only in the browser is a presentation feature. It is not validation, and it is not a security boundary.
A hidden field is hidden by CSS. Its <input> is in the DOM, its name is in the
POST body, and a respondent who unticks the box after typing has sent you a value
for a question that does not apply to them. If a require rule exists only in
applyConditions, then “required” means “the browser was polite about it”.
Which is exactly why FieldCondition.evaluate() exists, is tested operator by
operator —
self.assertTrue(condition.evaluate({"source": "yes"}))
self.assertTrue(condition.evaluate({"source": "YES"})) # case insensitive
self.assertFalse(condition.evaluate({"source": "no"}))
— and why the honest statement about our own code is that this evaluator is
not yet wired into the submission path: today it is the tested reference
implementation of the grammar, and the browser is what runs against a live form.
Closing that gap is a validation pass over FormSubmission.data that drops
values whose field was not applicable and enforces conditional require. If you
are building this, build that pass on day one. It is much harder to add after the
first year of stored submissions, because you then have to decide what the old
rows meant.
Two things the model refuses to let you say
A group can be shown or hidden. It cannot be required.
class GroupCondition(TimeStampedModel):
action = models.CharField(
choices=[("show", _("Show group")), ("hide", _("Hide group"))],
default="show",
)
Six actions on a field, two on a group. “Require this section” has no meaning — requiredness is a property of an answer, and a section is not an answer. Rather than accept the word and quietly apply it to the section’s fields (which is what the customer might mean, and might not), the enum does not offer it. A dropdown that cannot express a meaningless rule is better documentation than a paragraph explaining that the rule is meaningless.
A field cannot depend on itself.
def clean(self):
if self.source_field.form_template_id != self.field.form_template_id:
raise ValidationError({"source_field": _("Source field must be from the same form template")})
if self.source_field_id == self.field_id:
raise ValidationError({"source_field": _("A field cannot have a condition based on itself")})
Both checks are cheap and both catch something real. The self-reference is an
infinite loop in the browser: changing the field re-evaluates its own condition,
which changes the field. The cross-template check catches a source field copied
along with a duplicated form, still pointing at the original — a rule that
evaluates against a field that is not on the page, so getFieldValue returns
empty, so the condition is quietly always false. Nothing errors. The field simply
never appears, and the customer reports that “conditions do not work”.
Neither of these can be caught at render time in a way the customer understands.
They belong in clean(), at the moment the rule is written, where the error
message can name the field.
Where the semantics are thinner than the model
One last piece of honesty, because it is the kind of thing an article normally omits and a reader normally discovers.
logic_operator — and / or — is on the model, is serialised into the JSON
handed to the browser, and is not consumed by the evaluator. What actually
runs is:
conditions.forEach(condition => {
const result = this.evaluateCondition(condition);
if (result) {
switch (condition.action) {
case 'show': shouldShow = true; break;
case 'hide': shouldShow = false; break;
case 'disable': shouldEnable = false; break;
case 'require': shouldRequire = true; break;
...
}
}
});
Per axis, the last matching rule wins, in position order —
Meta.ordering = ["field", "position"], so the order is deterministic and the
customer controls it by reordering rules.
That is a real semantics, and for the common case it coincides with OR: any
matching show rule shows the field. Where it differs from a true combinator is
AND — “show only when A and B” cannot be expressed, because either rule
matching is enough. The workaround customers reach for is a single rule on a
computed field, which works and is not obvious.
Two rules, then, for anyone designing this:
- Do not ship a column you do not evaluate. It reads as a feature in the interface and it is not one. Either implement the combinator or leave the column out until you do.
- Write down the resolution rule, whatever it is. “Last matching rule wins,
per axis, in position order” is a defensible specification. “It depends on the
order they came out of the database” is not — and they are the same code with
and without an
orderingon the model.
What to take away
- Show, enable and require are three axes. Fold them and you cannot express a visible-but-locked computed total, which is a real form.
- Derive the default state from the rules; never store it. Two copies of one fact drift.
- Use a third state for “no rule spoke”.
nullis notfalse. - Clearing
requiredon hide is mandatory, and you must record the original value to restore it — otherwise a hidden required field makes the submit button do nothing at all, with no message. - Whatever the browser decides is presentation. If the rule matters, the server has to evaluate it too, on the day you store the first submission.