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

  1. 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).
  2. Query it:
    query ($id: String!) {
      webformById(id: $id) {
        elements {
          __typename
          metadata { key type }
        }
      }
    }
  3. Expected: a clear, machine-readable indication that the
    form is unavailable, plus the reason / message.

    Actual: several WebformElementUnexposed entries
    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:

  • elements reuses the existing webform_elements
    producer, fed the model's render array — the same path it already uses to walk
    nested elements.
  • unavailable (and its message /
    type / reason) read the
    WebformFormUnavailable model through a dedicated property
    producer, the same pattern as WebformSubmissionResult; enum fields
    go through toGraphQlEnumValue() as enum fields do today.
  • title, sourceEntityType,
    sourceEntityId and sourceEntityLabel reuse the
    current Webform resolvers unchanged
    (webform_title, fromContext, entity_load
    + entity_label) — they are simply re-registered on
    WebformForm and read the source entity from the field context that
    the form field 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: elements is 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 a submissionId, or a test/preview mode — none of which have
    anywhere to live under the current Webform.elements shape.

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 WebformForm and WebformFormUnavailable
    types to the GraphQL schema, and the matching read-only model classes
    src/Model/WebformForm.php and
    src/Model/WebformFormUnavailable.php (same style as the existing
    src/Model/WebformSubmissionResult.php).
  • Add the WebformMessageType and
    WebformFormUnavailableReason backed enums under
    src/Enum/ (matching the existing enum convention) and wire them
    into the schema builder. WebformMessageType does not exist in 3.x
    and is new here.
  • Add a webform_form data producer that builds the form once
    (passing the supplied source entity into the build) and returns a
    WebformForm model, mirroring how webform_submit
    returns a WebformSubmissionResult. Wire it as the
    Webform.form(...) resolver, also setting the webform
    and source-entity field context (scoped to the form(...) field)
    that the reused resolvers below read.
  • Resolve WebformForm.elements by reusing the existing
    webform_elements producer against the model's render array, and
    WebformForm.unavailable (plus its message /
    type / reason) through a dedicated property producer
    reading the WebformFormUnavailable model — the same pattern as
    WebformSubmissionResultProperty. The webform_form
    producer reads the message/type from the appended status message and
    classifies reason from the webform's public state.
  • Register WebformForm.title / sourceEntityType /
    sourceEntityId / sourceEntityLabel by reusing the
    current Webform resolvers unchanged (webform_title,
    fromContext, entity_load + entity_label),
    and remove title and the sourceEntity* fields from
    Webform.
  • Remove Webform.elements.
  • Update tests: convert the existing elements queries / kernel
    tests to go through webformById { form { elements } }, and add a
    test asserting form { 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 immutable Webform configuration entity. Represented by the
    new WebformForm type.
  • 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
    query webform { form { elements } }.
  • Webform.title and
    Webform.sourceEntityType / sourceEntityId /
    sourceEntityLabel now live on WebformForm; read them
    via the form field, 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
    WebformMessageType and WebformFormUnavailableReason
    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.

Command icon 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

pfrenssen created an issue. See original summary.

pfrenssen’s picture

Issue summary: View changes

  • pfrenssen committed 8332f3fb on 3.x
    feat: #3595276 Expose the webform elements on the built form instead of...
pfrenssen’s picture

Status: Active » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.