← Omkar Khadamkar Bulk edit
Bulk edit · the model

The model in full

What the state model takes and returns, how it meets each of the seven hard cases, and what building on it caught. Every figure on this page comes from running the code, and names the file it came from.

Contents
  1. 01What it takes, what it returns
  2. 02The verbs come from the field
  3. 03The seven hard cases, and where each is met
  4. 04Computed, with the dataset named
  5. 05The tests
  6. 06What building on it caught
01What it takes, what it returns
plan(items, field, method, operand, excluded = [])

It takes the selected items, the field, a method, the value to apply, and any items the operator has taken out. It returns one row per item, with the value before and after, and each row in one of three states:

StateWhat it means
changingThe operation changes this item, and it will be changed.
excludedThe operation would change it, but the operator took it out.
unchangedThe operation does nothing here: the item already matches.

Excluded and unchanged are never one number. One is a decision the operator made; the other is work the system found nothing to do. Collapsing them hides a decision.

It commits nothing. A commit is asynchronous, has side effects, and belongs to whatever system owns the data, so the model stays out of it. What it does own is the report afterwards, because that is the last place a bulk edit gets to lie. The rest of the model answers the questions around plan():

FunctionWhat it answers
summarise()What does this field look like across the selection? Every value present, with its count, instead of the word Mixed.
methodsFor()Which verbs are legal for this kind of field?
groupByTransition()What classes of change does this plan contain?
describe()What does the plan say in one line, without rounding or flattering?
reconcile()What actually happened? It never reports attempted as succeeded, and retryScope() gives a retry the failures and nothing else.
planBatch()What do several operations do together? It reduces the batch to the shortest list with the same outcome, instead of calling anything a conflict.
02The verbs come from the field

The available verbs change with the kind of field. That is why bulk edit cannot be a form with a Save button.

FieldWhat it holdsWhat you can legally do
EnvironmentOne value per hostSet — nothing else is meaningful
TagsA list per hostAdd, Remove, Replace, Clear
MonitoringOn or offEnable or Disable — never toggle
Log retentionA number, 1–365 daysSet, Increase by, Decrease by

Toggle is the interesting one. A switch on mixed state has no defined meaning. Half are on, half are off — toggle them to what? Every one I have used ships a toggle here, which means it has already decided the answer, and none of them says which.

Relative numbers are the other one. “Decrease retention by 14 days” moves each host from its own current value, not from a shared one, and clamps at the field’s floor. A host already at the minimum does not change at all. The operation succeeds and does nothing, and the interface has to say so.

03The seven hard cases, and where each is met
Hard caseWhat it demandsWhere it is met
Mixed valuesThe field must never show a value the selected items do not share.Groups by transition. There is no single-value field to lie with.
Method per field typeThe legal verbs change with the kind of field.methodsFor(field) — a boolean is never offered a toggle.
No-op honestyAn operation that changes nothing has to say so.describe() reports what already matches on its own, never inside the count of what changes.
Per-item previewYou can see what happens to each item, not only the total.Expand any group: before → after, per host.
Partial failureSome succeed, some fail, and the retry must know which.reconcile(). The retry touches only what failed.
Scope honesty“All on this page” and “all matching the filter” cannot be the same click.The selection bar states both. Deselect one and it says so.
KeyboardA tool used many times a day cannot be mouse-only.Esc backs out of every state except a commit in flight.

All seven are met, and each is checkable in the component on the main page rather than on my word.

04Computed, with the dataset named

The main page shows plan() on three made-up hosts. This is the same call on the repo’s demo data:

plan(hosts, tags, 'add', 'monitored')
→ 22 will change · 2 already match

Generated by npm run figures, from demo/hosts.json, 24 hosts.

And the grouping that section 03 of the main page illustrates, on the 200 hosts its component loads, with Environment set to prod:

groupByTransition(plan(hosts, environment, 'set', 'prod'), …) staging → prod 123 hosts dev → prod 70 hosts already prod 7 hosts · no change

Generated by npm run figures, from demo/fixture.js, 200 hosts.

Three groups, because the field has three values. The component draws the same three: choose All 200 matching this filter, then Bulk edit 200 hosts, and it opens on Environment set to prod.

05The tests
spec/model.test.js → 53 tests

Counted by npm run figures, which runs the suite.

They encode what products usually get wrong: a mixed field that refuses to pick a winner, a boolean that is never offered a toggle, a numeric change that clamps and says so, excluded and already-matching never counted together, a partial failure never reported as a success, and a retry that touches only what failed. If one of them breaks, the interface on top is lying to someone.

A second file, spec/published-figures.test.js, recomputes every number on this page and fails if demo/figures.json no longer matches, naming this page in the failure. npm test runs both, on every push.

06What building on it caught

The interface is built, as a web component on this model, and it runs on the main page.

Four treatments of several operations at once went into an evaluation, and it declined to name a winner; the one I had ranked first came last. So the tie was mine to break, and it broke on a single line of the reasoning: a dead operation you must click a tab to discover is one you will not discover. That is why it is one lane per operation, all of them on screen, and no tabs.

All seven hard cases behave, including the two that took longest. Scope honesty: the moment a selection becomes “all matching this filter”, the header checkbox still means exactly one thing, and deselecting one host from an all-matching selection says so rather than letting the number quietly slide. And several operations at once, where the honest answer turned out to be this is the same as… and never these conflict: nothing is blocked, no ordering is chosen for anyone, and a superseded operation reads as “changes nothing — Set Log retention to 1d undoes it”.

Plus a pass on what gets expensive to retrofit once there is a visual layer. Focus used to be destroyed on every render, which made the whole thing mouse-only in practice; group headers were divs, so the central interaction could not be reached by keyboard at all; focusable elements had no accessible names. All fixed, and the counts that move when you exclude a host are now announced rather than changing in silence.

And building it caught three things that three evaluations did not. The batch has to become the truth the moment one operation is banked, not at two — gated at two, a single banked operation was still being previewed by the emptied picker, and the button offered to change every host instead of only the ones that would. The button, the summary line and the lane list all have to read from the same place; two of the three were still reading the single-operation plan. And a new piece of state has to be added in two places, because the reset path rebuilds the whole object — added in one, it threw on the first click.

None of those are model bugs. The model needed no change and no test moved. They are all the same fault: two parts of an interface disagreeing about what is true — which is precisely what this component exists to stop happening between a field and its value.