Govern a field's display and default from the site, tenant or resource type
Both halves of the issue. Nothing is governed until a level says so, so a site that makes no rule behaves exactly as it did before this lands.
The mechanism
GovernedDisplay: shown, hidden, read-only. Turning a feature off stops being a separate idea from enforcing a house rule: off is hidden with an empty value, a house rule is read-only with the value, and today's behavior is shown with nothing enforced.FieldGovernance: walks resource type, then tenant, then site, and the nearest level that decides wins. A level answering "shown" decides too, which is how a tenant reopens what the site closed.- Enforced on read.
value()returns the deciding level's value whatever the row underneath holds. Nothing is deleted, so relaxing a rule restores what the rows held. hook_yoyaku_governed_fields(): each module declares its own, since the fields worth governing belong to the engine, placement, payment, ticketing and the calendar. The engine declares the five that already carry hand-written "inherit from type" labels.refuse(): hiding a required field with nothing in its place is refused where the rule is made, not where a form silently fails.
Where a rule shows
On the widget, beside InheritedSelect, rather than on each form: a governed field is then governed on every form it appears on, including the ones nobody has thought of yet, which is the lesson of the four hand-written relabels one helper replaced.
- Hidden removes the widget with
#access, which also makes core ignore a value posted for it anyway. Hiding by CSS or by#disabledwould leave the posted value standing, and "the feature is off" would mean "off unless you craft a request". - Read-only replaces the widget with the value and the level that set it, in words: "When the slot ends, set on the tenant." Not by color alone, because a grayed box says only that somebody, somewhere, took the field away, and the operator's next move is a support request.
Where a rule is made
One screen, shared by the site, a tenant and a resource type, driven by GovernedFields::all() so none of the three can drift into describing a rule the engine does not apply. Saying nothing stays distinct from saying shown: the first falls through to the level above, the second reopens what a wider level closed. refuse() runs in each form's validation, against the row that made the rule.
The values a level may enforce are read from the field's own definition, not declared a second time by the hook, so a rule cannot enforce a value the field would reject.
What obeys it
Every reader of the five declared fields now goes through FieldGovernance: the booking mode, unit, cancellation and settlement resolvers, and the resource's own booking cutoff. A screen that stopped asking about a field while the engine went on reading the row would be worse than no screen at all.
FieldGovernanceInterface is extracted so those resolvers' own tests can still answer "and what if nothing governs this?" in one line, and forResource() / ruleForResource() exist so no caller repeats the tenant-and-type read (getTenantId() returns an empty string, not NULL, for an unset tenant).
Two things worth knowing
Rules are stored as a list naming each field, not keyed by the field id, because a Drupal configuration key may not contain a dot and the ids read far better with one: yoyaku_resource.booking_cutoff says where the field lives. Found by the tests, not by reading the docs.
Reading the rules back has to be idempotent. A config entity form copies its values onto the entity from an #after_build callback and again on submit, and the first call replaces the widget's nested shape with the list it returns. A reader that understood only the widget shape answered "no rules" the second time and wiped everything the operator had just made.
One fix that rides along
A #states condition naming a governed-away field is never met, because the input is not on the page. The lead-time fields watch the booking cutoff, so governing the cutoff would have collapsed both of them for good on a site that fixes it at the slot start. They are now gated server-side from the value that applies whenever the cutoff is not answerable.
Tests
Six kernel tests on the chain, and eight more on the forms: hidden leaving nothing on the page, read-only naming its value and its level, an ungoverned field still answered, the settings form storing and refusing, reading the rules back twice, and every reader obeying a rule that contradicts the row. Rendered markup rather than element arrays, because an inaccessible element and a rendered one look identical in the array and opposite on the page. A test module declares one field as required, since none of the engine's five is and refuse() would otherwise be unreachable.
Docs and French strings in the same commits, including four config-schema labels and two refusal messages the first commit missed.
Not here
The offer stepper's dropped-click race, which has been costing unrelated merge requests a pipeline roughly one run in two, is fixed in [#3615940] instead: that issue moves the stepper and its click queue into yoyaku_presentation, so the retry belongs at its dispatch rather than in a copy of the old script here. This merge request touches nothing under yoyaku_calendar, so the two do not overlap.