Problem/Motivation

Currently working on the nuxt-formkit-webform Nuxt module that integrates graphql_webform with the FormKit form library. It maps webform elements to matching FormKit inputs and its aim is to support as much of webform's features as possible.

This required exposing more fields on element types and introducing new GraphQL interfaces to share common fields between these element types. In addition it revealed a couple of bugs in existing resolvers, but especially in the webform_submit producer.

I have tried to keep each new element + bug fix in single commits, but resolving elements + handling submission for all of them basically required changes in a handful of files across all of them.

API changes

Query.webformById

  • Pre-existing sourceEntityType / sourceEntityId args now actually reach form build - they were stashed on the field context but WebformElements called getSubmissionForm() with no arguments, so [webform_submission:source-entity:*] tokens never resolved. Now passed as entity_type / entity_id values to getSubmissionForm($values, 'api') inside the sub-request.
  • New prepopulate: String - URL-encoded query string (e.g. address[city]=Cologne&color=red). Honours the webform's form_prepopulate setting and per-element #prepopulate: true opt-in. Reserved source_entity_* keys are stripped.

Webform.elements

  • Elements with #access FALSE (display_on: view / none, permission denials, custom access) are filtered out.
  • Form is built in api mode inside a sub-request to entity.webform.canonical. The sub-request prevents the GraphQL endpoint's graphql_server route param from leaking into source-entity detection; api mode prevents Webform from disabling elements that aren't on the current wizard page (i.e. everything past the first page).
  • When WebformSubmissionForm::getCustomForm() rejects the render tree (closed form, source-entity mismatch, limit reached, …), the element list collapses to a single fake WebformElementWebformMessage keyed unavailable. Temporary workaround, the proper fix is a new WebformForm type.

Cacheability

  • Per-element #cache (tags / contexts / max-age) is bubbled to the GraphQL field context at every tree level.
  • Form-root bubbles tags + contexts; the max-age = 0 that EntityFormDisplay::buildForm() always adds is dropped (it'd make every webform response uncacheable for reasons that don't apply). Per-element max-age preserved, so time-token cacheability ([date:medium], etc.) still works.
  • #default_value resolution is rerouted through a dedicated webform_element_default_value producer that re-runs token replacement with its own BubbleableMetadata - otherwise token cacheability was lost when the form-root max-age got stripped. Debatable whether this is a good thing or now, but it at least allows us to respect cacheability for default value tokens (e.g. time-based tokens that always require a max-age of 0).
  • Form build wrapped in a RenderContext to catch cacheability that leaks the form's cache metadata (e.g. WebformComputedToken::computeValue).

Mutation.submitWebform

I tested every element type for "submissability", both in single and multi value formats.

  • *_other variants: value + "other" input split correctly; composite-with-other is handled.
  • Checkboxes: the all and none values are normalized.
  • Date / datelist input parsed into the element's expected shape; multi-value date submissions validated.
  • Custom composite with #multiple: value shape fixed.

#states

  • Wizard pages no longer drop element states. Previously the resolved states would get lost for all elements inside pages 2+.
  • Inverted conditions (e.g. ['checked' => FALSE]) now return the correct trigger + value pair instead of the positive trigger with an empty value. It basically fixes the bug with the "same" element, where checking e.g. "shipping same as billing" would show the target element instead of hiding it.

Data model changes

Per-plugin fields added on auto-generated types

Support for more elements was added:

  • webform_message - body, type, close, id.
  • webform_terms_of_service.
  • webform_computed_token / webform_computed_twig - computedValue, mode, computedDisplayOn.
  • webform_email_confirm - confirm-input title / description / placeholder, flexbox.
  • webform_autocomplete - items, limit, match length, match operator.
  • webform_same - source, destination.
  • webform_likert.
  • view - name, displayId, arguments (metadata only, view is not executed).
  • entity_autocomplete - defaultEntities resolved to {id, label} pairs.
  • *_other variants of select / radios / checkboxes.

New interfaces

  • WebformElementNumericBase - min / max / step for plugins extending NumericBase.
  • WebformElementSelectBase - emptyOption.
  • WebformElementCheckboxesBase - optionsAllValue / optionsNoneValue.
  • WebformElementOptionsDisplayBase - orientation, randomize, etc.
  • WebformElementComputedBase - shared computed-element fields.

New fields on existing types

  • Textfield / Textarea / WebformAutocomplete - counter, inputMask, inputHide.
  • Date / Datelist / Datetime - part order, year range, increment, text parts, placeholders, time min / max / step.
  • Tel - international picker config, per-format validation.
  • Color - colorSize.
  • Details - open.
  • Number / Range - size, placeholder, autocomplete, slider output placement + prefix / suffix.
  • Section - titleTag.
  • Signature - readonly.
  • Time - min / max / step (string-typed, distinct from the numeric triplet).
  • Flexbox - alignItems.
  • Actions - submitLabel.
  • Containers - randomize.
  • Options elements - optionsRandomize (resolver unshuffles stored options for stable output); groupLabel on nested groups.
  • Term select - optionsWithDepth(depth: Int).

Todos

The biggest todo is the introduction of a new WebformForm type. The current workaround of emitting a fake webform_message element when resolving elements at least allows fetching the form that is unavailable.

The elements field would move to this new type. The type would also have a new unavailable field that properly resolves the reason for why the form is unavailable (and maybe even an enum).

The current schema doesn't allow this: The elements producer currently "owns" the call to getSubmissionForm. It would have to return an empty array in this case. A new resolver for the unavailable field would then have to call the same method again, just to determine if the returned render array is actually the webform and not the "custom form" render array that contains the message.

Doing so also makes sense from a data structure way: The Webform is the entity and WebformForm is the actual form. This split would also allow us to move sourceEntityLabel etc. into this new type, because it belongs to the form, not the webform entity.

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

dulnan created an issue. See original summary.

pfrenssen’s picture

Thanks, this is very impressive, but a lot to go through. I only had a brief look at the changes but it looks overall very good. The architecture is followed and there is test coverage. However it is too big to merge in a single ticket so we will need to split it up in some manageable chunks. The timing is right, we just opened the 3.x branch and are accepting breaking changes.

I see in the issue description that there are workarounds present in the MR because of how the elements are currently exposed:

When WebformSubmissionForm::getCustomForm() rejects the render tree (closed form, source-entity mismatch, limit reached, …), the element list collapses to a single fake WebformElementWebformMessage keyed unavailable. Temporary workaround, the proper fix is a new WebformForm type.

It is probably a good idea to treat this as a blocker so we can have a clean base and don't add in known workarounds.

To handle this I have created #3595276: Expose the webform elements on the built form instead of on the configured form. Two points for coordination with this MR:

  1. The temporary buildUnavailableElements() / synthetic WebformElementWebformMessage hack here is superseded by the new WebformForm.unavailable { message, type, reason } field. Once the WebformForm issue lands, that workaround (and its #custom_form branch in the elements producer) can be dropped.
  2. Source entity + prepopulate move with elements onto form(). This MR already does the hard part — building with entity_type / entity_id via the entity.webform.canonical sub-request, plus the RouteParametersWebformSourceEntity leak fix. That machinery is precisely what a source-entity-aware form(sourceEntityType, sourceEntityId) resolver needs, so it should hang off the new field rather than Webform.elements.

When the WebformForm issue lands (should be done before this MR), then 3.x will have a bare getSubmissionForm() (without source entity), so SOURCE_ENTITY_REQUIRED / SOURCE_ENTITY_TYPE_MISMATCH will not be included as reasons why a webform is not available. We should add those 2 reasons here when #3595276: Expose the webform elements on the built form instead of on the configured form is done.

pfrenssen’s picture

I merged in the blocker #3595276: Expose the webform elements on the built form instead of on the configured form. This caused a huge conflict because the new WebformForm element changes every single test query and result. Also the existing code that worked around the absence of WebformForm had to be removed.

Due to the vast size of the MR I could not manage this in a humanly dignified manner and enlisted Claude Code to solve the conflict and eliminate the workaround. I _think_ it did a good job, tests are passing, and no significant changed were introduced to the test coverage, even though some sections were refactored, most notable regarding prepopulation.

@dulnan it would be great if you can have a look at the recent commits and check if it still aligns with your original vision.

I will now split up this work into manageable chunks and review + commit them one by one. As the work progresses and parts are accepted into the main 3.x branch I will systematically merge them back in here so we should see the MR shrink over time.

Big thanks to Liip for sponsoring the work on this for both @dulnan and me!

pfrenssen’s picture

There is a new field introduced on Term select: optionsWithDepth(depth: Int) but this is not necessary. It is duplicating the existing field options(depth: Int) which already offers an optional depth argument.

pfrenssen’s picture

pfrenssen’s picture

pfrenssen’s picture

pfrenssen’s picture

pfrenssen’s picture

pfrenssen’s picture

pfrenssen’s picture

pfrenssen’s picture

Merged #3599006: Specialized elements. Now let's see what is left.

pfrenssen’s picture

Filed #3602639: Test coverage gaps from the #3595219 split: term-select no-depth, time-token cacheability, empty-string submissions to port over the last remaining code. When that is done this can be closed. Thanks very much @dulnan for the work and to Liip for sponsoring these major additions to the 3.x branch.

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.