Problem/Motivation
In the GraphQL schema, the list of form fields is exposed as
Webform.elements:
type Webform { elements: [WebformElement] # … }
To resolve this, the module asks the Webform module to build the actual
submission form ($webform->getSubmissionForm()) and then walks
the resulting render tree to expose each element.
The problem is that a "webform" and a "built form" are not the same
thing. A Webform is a configuration entity — it is fixed.
A built form, on the other hand, is an instance that depends on
context: the source entity, the current user's access, whether the form is open
or closed, whether submission limits have been reached, and so on.
Because the schema puts elements directly on
Webform, it has no place to represent anything about that built
instance — most importantly, whether the form is actually available to
fill in, and if not, why.
This becomes a concrete bug when a form is not available. When Webform's form
builder decides the form should not be shown (closed form, missing or mismatched
source entity, submission limit reached, confidential form for a logged-in user,
broken/empty element definition, …), it does not return the
form fields. Instead it returns a small render array containing only a status
message explaining why the form cannot be shown.
The elements resolver has no way to know this happened. It walks
that status-message render array as if it were a list of form fields and
produces a handful of meaningless WebformElementUnexposed entries.
From the consumer's point of view, a closed form looks like a form with a few
broken, unidentifiable fields — there is no clean signal that the form is
unavailable, and no way to read the reason or the message Drupal would have
displayed.
Resolving this is a prerequisite for the larger work in
#3595219,
which needs a correct, machine-readable representation of form availability to
build on.
Steps to reproduce
- Create a webform and set its status to Closed (or configure it to
require a source entity, or reach a submission limit — any case that makes the
form unavailable). - Query it:
query ($id: String!) { webformById(id: $id) { elements { __typename metadata { key type } } } } - Expected: a clear, machine-readable indication that the
form is unavailable, plus the reason / message.
Actual: severalWebformElementUnexposedentries
in place of the form fields, with no indication that the form is
unavailable.
Proposed resolution
Introduce a dedicated type that represents a built form instance,
and expose it through a field on Webform rather than putting
elements directly on the entity:
type Webform { """A built form instance for this webform.""" form( sourceEntityType: String sourceEntityId: ID # Reserved for follow-ups: operation, submissionId. ): WebformForm # … id, label, settings stay on Webform … } type WebformForm { """The form elements. Empty when the form is unavailable.""" elements: [WebformElement] """ Set when the form cannot be filled in (closed, source-entity required / mismatched, submission limit reached, confidential, …). NULL when the form is available. """ unavailable: WebformFormUnavailable """ The form title, taking the source entity into account: the webform title, the source-entity title, or a combination of both. """ title: String! """The type of the source entity this form was built for, if any.""" sourceEntityType: String """The ID of the source entity this form was built for, if any.""" sourceEntityId: ID """The label of the source entity this form was built for, if any.""" sourceEntityLabel: String } type WebformFormUnavailable { """The human-readable message Webform would display in place of the form.""" message: String! """The message severity (status / info / warning / error).""" type: WebformMessageType """ A machine-readable classification of why the form is unavailable, so consumers can branch (e.g. show a date picker for OPENING vs. a generic notice for CLOSED) without parsing the message text. OTHER covers any reason we do not classify explicitly. """ reason: WebformFormUnavailableReason! } enum WebformFormUnavailableReason { """The form is closed (manually or past its close date).""" CLOSED """The form is scheduled to open at a later date.""" OPENING """The form requires a source entity and none was supplied.""" SOURCE_ENTITY_REQUIRED """A source entity was supplied but its type does not match the form's required type.""" SOURCE_ENTITY_TYPE_MISMATCH """Unavailable for a reason we do not classify (locked, limit reached, confidential, broken definition, …). The message still explains it.""" OTHER }
The severity type is a new WebformMessageType enum
(status / info / warning / error) added by this change — 3.x has no message-type
enum yet. message and type come straight from the
status message Webform appends, so they are always present, translated, and
authoritative.
The form(...) field takes sourceEntityType /
sourceEntityId arguments, mirroring the existing
webformById query arguments. The source entity genuinely affects a
built form (and its availability — see the
SOURCE_ENTITY_REQUIRED / SOURCE_ENTITY_TYPE_MISMATCH
reasons), so it belongs to the form. For the same reason the
sourceEntityType / sourceEntityId /
sourceEntityLabel fields — and title, which also
depends on the source entity — live on WebformForm rather than
Webform: a single query can build several forms for different
source entities (a: form(sourceEntityId: "1") { … } and
b: form(sourceEntityId: "2") { … }), which fields placed on the
Webform entity cannot represent. Webform keeps only
what is intrinsic to the configuration entity: id,
label, and settings.
Key implementation point — the form is built only once. A new
webform_form data producer calls getSubmissionForm() a
single time and returns a small, read-only WebformForm model object
(src/Model/WebformForm.php) carrying the built render array and —
when the form is unavailable — a WebformFormUnavailable model with
the message, severity, and reason. This mirrors how the existing
webform_submit producer returns a
WebformSubmissionResult model.
The WebformForm fields then resolve off that model using
producers already in the module, so the change stays small:
elementsreuses the existingwebform_elements
producer, fed the model's render array — the same path it already uses to walk
nested elements.unavailable(and itsmessage/
type/reason) read the
WebformFormUnavailablemodel through a dedicated property
producer, the same pattern asWebformSubmissionResult; enum fields
go throughtoGraphQlEnumValue()as enum fields do today.title,sourceEntityType,
sourceEntityIdandsourceEntityLabelreuse the
currentWebformresolvers unchanged
(webform_title,fromContext,entity_load
+entity_label) — they are simply re-registered on
WebformFormand read the source entity from the field context that
theformfield sets from its arguments.
Because the build happens once in the webform_form producer and
both elements and unavailable read its result, the form
is never built twice — which is what makes splitting availability out of
elements practical.
Why this is the right fix
- It separates immutable webform configuration
(Webform) from a context-dependent built form
instance (WebformForm), which is what GraphQL
"field with arguments returning an object type" is designed to express. - It gives form availability a first-class, machine-readable home
(unavailable) instead of leaking placeholder elements. - It keeps the element list honest:
elementsis empty when there
is no form to fill in, instead of being padded with meaningless entries. - It costs nothing in extra form builds.
- It leaves room for future arguments such as
operation: "edit"
with asubmissionId, or a test/preview mode — none of which have
anywhere to live under the currentWebform.elementsshape.
How reason is determined (and what we deliberately do not do)
The build remains the single source of truth for whether a form is
available — unavailable is non-NULL if and only if the
built form carried Webform's "custom form" marker. reason is a
thin, best-effort classification layered on top, computed from the
webform's own public state (isClosed(), isOpening(),
the form_prepopulate_source_entity* settings vs. the supplied
source entity). We do not reproduce Webform's full
getCustomForm() decision tree, and we do not try to recover the
exact internal message key from the render array (it is not preserved there).
That keeps the classification honest and cheap: we only label the handful of
cases headless consumers commonly branch on, and everything else maps to
OTHER while still carrying Webform's real message. Less common
gates (locked submission, total/user submission limits, confidential form,
broken/empty element definition, results-disabled exception) are intentionally
left as OTHER for now — they can be promoted to their own enum
cases later if users ask for them, without a breaking change (adding enum values
is additive).
Remaining tasks
- Add the
WebformFormandWebformFormUnavailable
types to the GraphQL schema, and the matching read-only model classes
src/Model/WebformForm.phpand
src/Model/WebformFormUnavailable.php(same style as the existing
src/Model/WebformSubmissionResult.php). - Add the
WebformMessageTypeand
WebformFormUnavailableReasonbacked enums under
src/Enum/(matching the existing enum convention) and wire them
into the schema builder.WebformMessageTypedoes not exist in 3.x
and is new here. - Add a
webform_formdata producer that builds the form once
(passing the supplied source entity into the build) and returns a
WebformFormmodel, mirroring howwebform_submit
returns aWebformSubmissionResult. Wire it as the
Webform.form(...)resolver, also setting thewebform
and source-entity field context (scoped to theform(...)field)
that the reused resolvers below read. - Resolve
WebformForm.elementsby reusing the existing
webform_elementsproducer against the model's render array, and
WebformForm.unavailable(plus itsmessage/
type/reason) through a dedicated property producer
reading theWebformFormUnavailablemodel — the same pattern as
WebformSubmissionResultProperty. Thewebform_form
producer reads the message/type from the appended status message and
classifiesreasonfrom the webform's public state. - Register
WebformForm.title/sourceEntityType/
sourceEntityId/sourceEntityLabelby reusing the
currentWebformresolvers unchanged (webform_title,
fromContext,entity_load+entity_label),
and removetitleand thesourceEntity*fields from
Webform. - Remove
Webform.elements. - Update tests: convert the existing
elementsqueries / kernel
tests to go throughwebformById { form { elements } }, and add a
test assertingform { unavailable { message type reason } }for a
closed form. - Update documentation / README examples.
User interface changes
None (no Drupal admin UI). GraphQL schema/API changes are described below.
Introduced terminology
- Built form / form instance — a concrete render of a
webform for a given context (source entity, the current user, …), as opposed
to the immutableWebformconfiguration entity. Represented by the
newWebformFormtype. - Unavailable form — a built form that Webform refuses to
render as a fillable form and replaces with a status message. Represented by
WebformFormUnavailable.
API changes
This is a breaking schema change and should target the next
major release:
- Removed:
Webform.elements. Consumers must now
querywebform { form { elements } }. Webform.titleand
Webform.sourceEntityType/sourceEntityId/
sourceEntityLabelnow live onWebformForm; read them
via theformfield, e.g.
webform { form { title sourceEntityType } }.- Added:
Webform.form(sourceEntityType, sourceEntityId): WebformForm. - Added:
WebformForm { elements, unavailable, title, sourceEntityType, sourceEntityId, sourceEntityLabel }. - Added:
WebformFormUnavailable { message, type, reason }and the
WebformMessageTypeandWebformFormUnavailableReason
enums.
A change record will be published describing the migration from
Webform.elements to Webform.form { elements } once the
approach is agreed.
Data model changes
None. This is purely a GraphQL schema / resolver change; no Drupal config or
content entity schema is affected.
Release notes snippet
The webform's form fields are now exposed under a new form field
returning a WebformForm type
(webform { form { elements } }) instead of directly on
Webform.elements. When a form is unavailable (closed, missing
source entity, submission limit reached, …),
WebformForm.unavailable now reports a human-readable message, its
severity, and a machine-readable reason, instead of the form fields
being replaced by meaningless placeholder elements.
Issue fork graphql_webform-3595276
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
pfrenssenComment #5
pfrenssen