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/sourceEntityIdargs now actually reach form build - they were stashed on the field context butWebformElementscalledgetSubmissionForm()with no arguments, so[webform_submission:source-entity:*]tokens never resolved. Now passed asentity_type/entity_idvalues togetSubmissionForm($values, 'api')inside the sub-request. -
New
prepopulate: String- URL-encoded query string (e.g.address[city]=Cologne&color=red). Honours the webform'sform_prepopulatesetting and per-element#prepopulate: trueopt-in. Reservedsource_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
apimode inside a sub-request toentity.webform.canonical. The sub-request prevents the GraphQL endpoint'sgraphql_serverroute param from leaking into source-entity detection;apimode 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 fakeWebformElementWebformMessagekeyedunavailable. Temporary workaround, the proper fix is a newWebformFormtype.
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 = 0thatEntityFormDisplay::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_valueresolution is rerouted through a dedicatedwebform_element_default_valueproducer that re-runs token replacement with its ownBubbleableMetadata- 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
RenderContextto 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.
-
*_othervariants: value + "other" input split correctly; composite-with-other is handled. -
Checkboxes: the
allandnonevalues 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-defaultEntitiesresolved to{id, label}pairs.*_othervariants of select / radios / checkboxes.
New interfaces
WebformElementNumericBase-min/max/stepfor plugins extendingNumericBase.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);groupLabelon 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.
Issue fork graphql_webform-3595219
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 #3
pfrenssenThanks, 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:
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:
buildUnavailableElements() / syntheticWebformElementWebformMessagehack here is superseded by the newWebformForm.unavailable { message, type, reason }field. Once theWebformFormissue lands, that workaround (and its#custom_formbranch in the elements producer) can be dropped.elementsontoform(). This MR already does the hard part — building withentity_type/entity_idvia theentity.webform.canonicalsub-request, plus theRouteParametersWebformSourceEntityleak fix. That machinery is precisely what a source-entity-awareform(sourceEntityType, sourceEntityId)resolver needs, so it should hang off the new field rather thanWebform.elements.When the
WebformFormissue lands (should be done before this MR), then 3.x will have a baregetSubmissionForm()(without source entity), soSOURCE_ENTITY_REQUIRED/SOURCE_ENTITY_TYPE_MISMATCHwill 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.Comment #4
pfrenssenI 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
WebformFormelement changes every single test query and result. Also the existing code that worked around the absence ofWebformFormhad 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!
Comment #5
pfrenssenThere is a new field introduced on Term select:
optionsWithDepth(depth: Int)but this is not necessary. It is duplicating the existing fieldoptions(depth: Int)which already offers an optional depth argument.Comment #6
pfrenssenMerged in #3598964: States, wizard & source-entity/prepopulate.
Comment #7
pfrenssenMerged in #3598973: Options/selection elements.
Comment #8
pfrenssenMerged in #3598977: "Other" + composite/multiple submission.
Comment #9
pfrenssenMerged in #3598988: Numeric/text/misc fields.
Comment #10
pfrenssenMerged #3598994: Date/time field exposure.
Comment #11
pfrenssenMerged #3599000: Date/time submission.
Comment #12
pfrenssenMerged #3599006: Specialized elements.
Comment #13
pfrenssenMerged #3599006: Specialized elements. Now let's see what is left.
Comment #14
pfrenssenFiled #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.
Comment #15
pfrenssen