@iqual/nuxt-icms 1.6.1 → 1.6.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/README.md +86 -0
  2. package/__tests__/anchors.spec.ts +43 -0
  3. package/__tests__/contract/blokkliComponents.spec.ts +59 -0
  4. package/__tests__/contract/componentFragments.spec.ts +24 -0
  5. package/__tests__/contract/languages.spec.ts +63 -0
  6. package/__tests__/contract/project.ts +144 -0
  7. package/__tests__/hreflang.spec.ts +39 -0
  8. package/{tests → __tests__}/pages.spec.ts +14 -5
  9. package/app.config.ts +15 -0
  10. package/app.vue +3 -21
  11. package/assets/css/backend.css +56 -0
  12. package/assets/css/base/base.css +2 -2
  13. package/assets/css/base/typography.css +2 -2
  14. package/assets/css/ckeditor.css +3 -4
  15. package/assets/css/components/decorations.css +1 -1
  16. package/assets/css/components/editor.css +1 -1
  17. package/assets/css/components/grid-container.css +19 -19
  18. package/assets/css/fe-fixes.css +6 -12
  19. package/assets/css/theme/colors/color-primary.css +33 -11
  20. package/assets/css/theme/colors/color-secondary.css +33 -11
  21. package/assets/css/utilities/utilities.css +1 -1
  22. package/assets/symbols/anchor.svg +3 -0
  23. package/blokkli.editAdapter.ts +56 -0
  24. package/bundles.ts +6 -6
  25. package/cache/__tests__/pageCache.spec.ts +540 -0
  26. package/cache/cacheTagRegistry.ts +124 -0
  27. package/cache/enabledForRequest.ts +21 -0
  28. package/cache/multiCache.serverOptions.ts +58 -0
  29. package/cache/pageCache.ts +360 -0
  30. package/components/Blokkli/Anchor/icon.svg +3 -0
  31. package/components/Blokkli/Anchor/index.vue +13 -0
  32. package/components/Blokkli/AnchorNavigation/icon.svg +8 -0
  33. package/components/Blokkli/AnchorNavigation/index.vue +9 -0
  34. package/components/Blokkli/Card/Element/index.vue +0 -1
  35. package/components/Blokkli/Collapsible/Element/index.vue +5 -0
  36. package/components/Blokkli/FAQ/Element/index.vue +0 -1
  37. package/components/Blokkli/Fragment/Separator/icon.svg +4 -0
  38. package/components/Blokkli/Fragment/Separator/index.vue +87 -0
  39. package/components/Blokkli/Gallery/index.vue +1 -3
  40. package/components/Blokkli/IconText/Element/index.vue +0 -1
  41. package/components/Blokkli/Introduction/index.vue +0 -1
  42. package/components/Blokkli/Listing/icon.svg +9 -0
  43. package/components/Blokkli/Listing/index.vue +24 -0
  44. package/components/Blokkli/Logo/Element/index.vue +0 -1
  45. package/components/Blokkli/Table/index.vue +0 -1
  46. package/components/Blokkli/Team/Element/index.vue +0 -1
  47. package/components/Blokkli/Team/Layout/index.vue +0 -1
  48. package/components/Blokkli/Testimonial/Element/index.vue +0 -1
  49. package/components/Blokkli/Text/index.vue +0 -1
  50. package/components/DesignSystem/Atoms/Button.vue +3 -0
  51. package/components/DesignSystem/Atoms/GridRow.vue +3 -1
  52. package/components/DesignSystem/Molecules/ButtonGroup.vue +5 -4
  53. package/components/DesignSystem/Molecules/IconText.vue +6 -6
  54. package/components/DesignSystem/Molecules/MediaLightbox.vue +8 -8
  55. package/components/DesignSystem/Molecules/MediaSlider.vue +9 -11
  56. package/components/DesignSystem/Molecules/Partnership.vue +7 -4
  57. package/components/DesignSystem/Molecules/Table.vue +35 -19
  58. package/components/DesignSystem/Molecules/TitleComposition.vue +46 -15
  59. package/components/DesignSystem/Molecules/iFrame.vue +2 -2
  60. package/components/DesignSystem/Organisms/CallToAction.vue +8 -7
  61. package/components/DesignSystem/Organisms/CallToActionFeature.vue +8 -7
  62. package/components/DesignSystem/Organisms/CallToActionFullwidth.vue +9 -8
  63. package/components/DesignSystem/Organisms/ContentBlock.vue +26 -16
  64. package/components/DesignSystem/Organisms/FacetedListing.vue +38 -17
  65. package/components/DesignSystem/Organisms/FactCard.vue +6 -7
  66. package/components/DesignSystem/Organisms/FeatureCard.vue +14 -14
  67. package/components/DesignSystem/Organisms/FlipTeaser.vue +19 -19
  68. package/components/DesignSystem/Organisms/HeaderHero.vue +11 -11
  69. package/components/DesignSystem/Organisms/HeaderTextImage.vue +10 -10
  70. package/components/DesignSystem/Organisms/LinkList.vue +20 -12
  71. package/components/DesignSystem/Organisms/LogoElement.vue +7 -4
  72. package/components/DesignSystem/Organisms/Map.vue +1 -1
  73. package/components/DesignSystem/Organisms/SearchResults.vue +15 -6
  74. package/components/DesignSystem/Organisms/SpotlightCard.vue +18 -18
  75. package/components/DesignSystem/Organisms/TeamElement.vue +19 -18
  76. package/components/DesignSystem/Organisms/TeaserCard.vue +16 -17
  77. package/components/DesignSystem/Organisms/TeaserSimple.vue +4 -31
  78. package/components/DesignSystem/Organisms/Testimonial.vue +13 -13
  79. package/components/DesignSystem/Organisms/TextCard.vue +10 -10
  80. package/components/DesignSystem/Organisms/TimelineElement.vue +20 -19
  81. package/components/DesignSystem/Organisms/TwoColumnTextElement.vue +5 -5
  82. package/components/DesignSystem/README.md +59 -40
  83. package/components/Drupal/Breadcrumb/index.vue +25 -12
  84. package/components/Drupal/Messages/index.vue +9 -4
  85. package/components/Drupal/Webform/index.vue +271 -51
  86. package/components/Drupal/Webform/mutation.graphql +17 -1
  87. package/components/Form/HiddenInput.vue +1 -4
  88. package/components/Form/SelectInput.vue +1 -1
  89. package/components/Form/SubmitButton.vue +2 -2
  90. package/components/Form/TextAreaInput.vue +3 -3
  91. package/components/Form/TextEmailInput.vue +1 -1
  92. package/components/Form/TextInput.vue +1 -1
  93. package/components/Form/TextPhoneInput.vue +1 -1
  94. package/components/Form/WebformMarkup.vue +1 -1
  95. package/components/Form/composables/useFormDefaultValue.ts +9 -13
  96. package/components/Form/composables/useFormElement.ts +5 -3
  97. package/components/Form/utils/types.ts +3 -3
  98. package/components/Media/Image/fragment.graphql +4 -0
  99. package/components/Media/Image/index.vue +27 -17
  100. package/components/Media/RemoteVideo/index.vue +3 -3
  101. package/components/Media/Video/index.vue +1 -1
  102. package/components/Node/Icms/Page/SearchResult/fragment.graphql +1 -1
  103. package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
  104. package/components/Node/Icms/Page/Teaser/index.vue +1 -1
  105. package/components/Node/Icms/Page/index.vue +8 -2
  106. package/components/Paragraph/Anchor/fragment.graphql +4 -0
  107. package/components/Paragraph/Anchor/index.vue +48 -0
  108. package/components/Paragraph/AnchorNavigation/fragment.graphql +3 -0
  109. package/components/Paragraph/AnchorNavigation/index.vue +154 -0
  110. package/components/Paragraph/BlokkliFragment/fragment.graphql +5 -0
  111. package/components/Paragraph/Button/Element/fragment.graphql +1 -1
  112. package/components/Paragraph/Button/Element/index.vue +13 -8
  113. package/components/Paragraph/Card/Element/index.vue +1 -1
  114. package/components/Paragraph/Card/Layout/index.vue +9 -9
  115. package/components/Paragraph/Collapsible/Element/index.vue +2 -2
  116. package/components/Paragraph/Collapsible/Layout/index.vue +19 -18
  117. package/components/Paragraph/Contact/index.vue +17 -17
  118. package/components/Paragraph/Downloads/fragment.graphql +1 -1
  119. package/components/Paragraph/Downloads/index.vue +13 -13
  120. package/components/Paragraph/FAQ/Element/index.vue +4 -3
  121. package/components/Paragraph/FAQ/Layout/index.vue +42 -21
  122. package/components/Paragraph/FromLibrary/fragment.graphql +17 -0
  123. package/components/Paragraph/Gallery/index.vue +14 -14
  124. package/components/Paragraph/HeroImage/index.vue +26 -19
  125. package/components/Paragraph/IconText/Element/index.vue +4 -4
  126. package/components/Paragraph/IconText/Layout/index.vue +12 -12
  127. package/components/Paragraph/Introduction/index.vue +17 -17
  128. package/components/Paragraph/LinkLists/Element/fragment.graphql +1 -1
  129. package/components/Paragraph/LinkLists/Element/index.vue +5 -5
  130. package/components/Paragraph/LinkLists/Layout/index.vue +9 -9
  131. package/components/Paragraph/Listing/fragment.graphql +14 -0
  132. package/components/Paragraph/Listing/index.vue +91 -0
  133. package/components/Paragraph/Logo/Element/fragment.graphql +1 -1
  134. package/components/Paragraph/Logo/Element/index.vue +7 -4
  135. package/components/Paragraph/Media/index.vue +23 -21
  136. package/components/Paragraph/Partnerships/Element/fragment.graphql +1 -1
  137. package/components/Paragraph/Partnerships/Element/index.vue +10 -7
  138. package/components/Paragraph/Partnerships/Layout/index.vue +13 -13
  139. package/components/Paragraph/SingleTestimonial/index.vue +13 -13
  140. package/components/Paragraph/Table/index.vue +12 -12
  141. package/components/Paragraph/Team/Element/index.vue +16 -14
  142. package/components/Paragraph/Team/Layout/index.vue +9 -9
  143. package/components/Paragraph/Teasers/index.vue +14 -17
  144. package/components/Paragraph/Testimonial/Element/index.vue +19 -18
  145. package/components/Paragraph/Testimonial/Layout/index.vue +24 -18
  146. package/components/Paragraph/Text/fragment.graphql +1 -1
  147. package/components/Paragraph/Text/index.vue +8 -8
  148. package/components/Paragraph/TextMedia/index.vue +22 -22
  149. package/components/Paragraph/Timeline/Element/index.vue +19 -19
  150. package/components/Paragraph/Timeline/Layout/index.vue +51 -20
  151. package/components/Paragraph/TwoColumnText/Element/index.vue +13 -13
  152. package/components/Paragraph/TwoColumnText/Layout/index.vue +1 -1
  153. package/components/Paragraph/iFrame/index.vue +8 -8
  154. package/components/Search/Facets/FacetDropdownUI.vue +25 -31
  155. package/components/Search/Facets/FacetedSearchUI.vue +72 -48
  156. package/components/Search/Facets/fragment.graphql +20 -15
  157. package/components/Section/Footer/index.vue +55 -32
  158. package/components/Section/Header/Buttons.vue +4 -5
  159. package/components/Section/Header/DesktopMainMenu.vue +5 -4
  160. package/components/Section/Header/DesktopMainMenuDropdown.vue +5 -5
  161. package/components/Section/Header/LanguageDropdown.vue +2 -3
  162. package/components/Section/Header/Logo.vue +3 -3
  163. package/components/Section/Header/MainButtons.vue +4 -5
  164. package/components/Section/Header/MetaButtons.vue +5 -6
  165. package/components/Section/Header/MetaMenu.vue +1 -1
  166. package/components/Section/Header/MobileMainMenu.vue +1 -1
  167. package/components/Section/Header/MobileMainMenuItem.vue +2 -2
  168. package/components/Section/Header/index.vue +6 -13
  169. package/composables/__tests__/useImageSources.spec.ts +91 -0
  170. package/composables/breadcrumb.ts +12 -7
  171. package/composables/useAccordionScroll.ts +12 -4
  172. package/composables/useAllowedFragments.ts +14 -0
  173. package/composables/useAnchors.ts +48 -0
  174. package/composables/useChildTitleWeight.ts +1 -1
  175. package/composables/useCurrentLangcodeEnum.ts +1 -1
  176. package/composables/useCurrentNodeId.ts +8 -0
  177. package/composables/useDrupalUrl.ts +15 -0
  178. package/composables/useEnrichFacetLabels.ts +8 -6
  179. package/composables/useEntityRegistry.ts +3 -3
  180. package/composables/useGlobalConfig.ts +1 -2
  181. package/composables/useGraphqlCacheability.ts +12 -1
  182. package/composables/useHreflangLinks.ts +54 -0
  183. package/composables/useImageSources.ts +70 -28
  184. package/composables/useInitData.ts +1 -2
  185. package/composables/useIsHomepage.ts +1 -1
  186. package/composables/useLinkUrl.ts +30 -0
  187. package/composables/useMediaLightbox.ts +62 -50
  188. package/composables/useMenu.ts +1 -3
  189. package/composables/useMessageBus.ts +14 -7
  190. package/composables/useMobileMenuOverlayBus.ts +1 -1
  191. package/composables/useNavigationOffset.ts +49 -6
  192. package/composables/usePageCache.ts +33 -0
  193. package/composables/useSiteShell.ts +11 -0
  194. package/composables/useTheme.ts +17 -13
  195. package/config/__tests__/stubs/nuxt-multi-cache-config.ts +2 -0
  196. package/config/blokkli.ts +7 -13
  197. package/config/easyTexts.ts +1 -1
  198. package/config/graphqlMiddleware.ts +11 -4
  199. package/config/index.ts +5 -2
  200. package/config/languageNegotiation.ts +1 -1
  201. package/config/multiCache.ts +6 -1
  202. package/config/postcss-drupal/postcss.config.cjs +2 -2
  203. package/config/runtimeConfig.ts +7 -2
  204. package/config/svgIconSprite.ts +1 -2
  205. package/config/vuepal.ts +16 -18
  206. package/error.vue +1 -22
  207. package/layouts/default.vue +2 -6
  208. package/layouts/error.vue +7 -15
  209. package/modules/blokkliFragments.ts +76 -0
  210. package/modules/dynamicEntityComponentResolver.ts +47 -47
  211. package/modules/dynamicParagraphResolver.ts +43 -8
  212. package/modules/pageCache.ts +106 -0
  213. package/nuxt.config.ts +62 -33
  214. package/package.json +20 -19
  215. package/pages/[...slug]/index.vue +25 -4
  216. package/pages/index.vue +3 -3
  217. package/pages/node/[nid]/index.vue +2 -3
  218. package/patches/@blokkli+editor+2.0.0-alpha.13.patch +15 -1
  219. package/plugins/currentNode.ts +17 -0
  220. package/plugins/graphqlMiddleware.ts +10 -2
  221. package/plugins/initData.ts +15 -7
  222. package/plugins/registerNodeIcmsPageComponents.ts +6 -2
  223. package/plugins/siteShell.client.ts +94 -0
  224. package/queries/facetTermLabels.graphql +1 -5
  225. package/queries/fragment.linkUrl.graphql +12 -0
  226. package/queries/query.contentSearch.graphql +17 -2
  227. package/queries/query.initData.graphql +1 -1
  228. package/scripts/create-shade.ts +13 -4
  229. package/scripts/update-icms.ts +3 -1
  230. package/server/__tests__/graphqlMiddleware.serverOptions.spec.ts +2 -2
  231. package/server/__tests__/turnstile.spec.ts +119 -0
  232. package/server/api/icms/blokkli/last-changed.get.ts +24 -0
  233. package/server/api/icon/[...params].ts +3 -9
  234. package/server/graphqlMiddleware.serverOptions.ts +9 -8
  235. package/server/helpers/index.ts +0 -1
  236. package/server/middleware/turnstile.ts +51 -0
  237. package/server/plugins/frameAncestors.ts +20 -0
  238. package/server/plugins/pageCache.ts +59 -0
  239. package/server/utils/__tests__/accessDeniedHandover.spec.ts +49 -0
  240. package/server/utils/__tests__/backendRequestHeaders.spec.ts +115 -0
  241. package/server/utils/accessDeniedHandover.ts +48 -0
  242. package/server/utils/backendRequestHeaders.ts +51 -0
  243. package/server/utils/cacheability.ts +4 -4
  244. package/shared/__tests__/blokkliState.spec.ts +54 -0
  245. package/shared/__tests__/csp.spec.ts +53 -0
  246. package/shared/__tests__/drupalUrl.spec.ts +50 -0
  247. package/shared/blokkliState.ts +40 -0
  248. package/shared/csp.ts +47 -0
  249. package/shared/drupalUrl.ts +30 -0
  250. package/tailwind.config.ts +0 -5
  251. package/types/breadcrumb.ts +22 -0
  252. package/types/global.ts +15 -14
  253. package/types/langcode.ts +16 -0
  254. package/types/layerConfig.ts +84 -0
  255. package/utils/pageCache.ts +95 -0
  256. package/utils/turnstile.ts +35 -0
  257. package/composables/useWebformEnrichment.ts +0 -68
  258. package/server/__tests__/graphqlMiddleware.ts +0 -41
  259. package/server/multiCache.serverOptions.ts +0 -32
  260. package/vitest.config.mts +0 -28
@@ -1,18 +1,57 @@
1
1
  <template>
2
- <div :class="themedClass('containerClass')">
2
+ <div ref="containerRef" :class="themedClass('containerClass')">
3
+ <div
4
+ v-if="submitted"
5
+ :class="
6
+ themedClass(
7
+ 'confirmationClass',
8
+ 'bg-success-100 text-success-800 flex items-start gap-4 rounded-sm p-6',
9
+ )
10
+ "
11
+ >
12
+ <CircleCheck
13
+ :class="themedClass('confirmationIconClass', 'mt-1 size-8 shrink-0')"
14
+ aria-hidden="true"
15
+ />
16
+ <div>
17
+ <p
18
+ :class="
19
+ themedClass('confirmationTitleClass', 'text-h5 font-semibold')
20
+ "
21
+ >
22
+ {{
23
+ confirmation?.title ||
24
+ $texts('webform.confirmation_title', 'Thank you!')
25
+ }}
26
+ </p>
27
+ <p
28
+ v-if="confirmationMessage"
29
+ :class="themedClass('confirmationMessageClass', 'mt-2')"
30
+ v-html="confirmationMessage"
31
+ />
32
+ <p v-else :class="themedClass('confirmationMessageClass', 'mt-2')">
33
+ {{
34
+ $texts(
35
+ 'webform.submit_success',
36
+ 'Your form has been submitted successfully.',
37
+ )
38
+ }}
39
+ </p>
40
+ </div>
41
+ </div>
3
42
  <UForm
43
+ v-else
4
44
  :class="themedClass('formClass', 'space-y-4')"
5
45
  :schema="schema"
6
46
  :state="state"
47
+ novalidate
7
48
  @submit="onSubmit"
8
49
  @error="console.log"
9
- noValidate
10
50
  >
11
-
12
51
  <DesignSystemAtomsButton
13
52
  v-if="debug"
14
- @click="console.log(schema.safeParse(state))"
15
53
  label="Debug Schema"
54
+ @click="console.log(schema.safeParse(state))"
16
55
  />
17
56
 
18
57
  <div
@@ -20,25 +59,42 @@
20
59
  :key="formElement?.metadata?.key"
21
60
  :class="themedClass('elementWrapperClass')"
22
61
  >
23
- <!-- <NuxtTurnstile
24
- v-model="token"
25
- v-if="formElement?.metadata?.key === 'actions'"
26
- /> -->
62
+ <NuxtTurnstile
63
+ v-if="
64
+ turnstileEnabled &&
65
+ formElement?.metadata?.key === turnstileAnchorKey
66
+ "
67
+ ref="turnstileRef"
68
+ v-model="turnstileToken"
69
+ :class="themedClass('turnstileClass', 'mb-4')"
70
+ />
27
71
 
28
72
  <component
29
- v-if="formElement?.metadata?.type"
30
73
  :is="getComponent(formElement?.metadata?.type)"
31
- :formElement="formElement"
32
- v-model="state[formElement.metadata && formElement.metadata.key ? formElement.metadata.key : '']"
74
+ v-if="formElement?.metadata?.type"
75
+ v-model="
76
+ state[
77
+ formElement.metadata && formElement.metadata.key
78
+ ? formElement.metadata.key
79
+ : ''
80
+ ]
81
+ "
82
+ :form-element="formElement"
33
83
  :class="themedClass('elementClass')"
34
84
  :debug="debug"
35
85
  />
36
86
  </div>
87
+ <NuxtTurnstile
88
+ v-if="turnstileEnabled && turnstileAnchorKey === null"
89
+ ref="turnstileRef"
90
+ v-model="turnstileToken"
91
+ :class="themedClass('turnstileClass', 'mb-4')"
92
+ />
37
93
  <DesignSystemAtomsButton
38
94
  v-if="debug"
39
- @click="console.log(schema.safeParse(state))"
40
95
  label="Debug Schema"
41
- :buttonStyle="'focus'"
96
+ :button-style="'focus'"
97
+ @click="console.log(schema.safeParse(state))"
42
98
  />
43
99
  </UForm>
44
100
  </div>
@@ -46,6 +102,7 @@
46
102
 
47
103
  <script lang="ts" setup>
48
104
  import { z } from 'zod'
105
+ import { CircleCheck } from 'lucide-vue-next'
49
106
  import type { FormSubmitEvent } from '@nuxt/ui'
50
107
  import {
51
108
  FormTextInput,
@@ -62,6 +119,13 @@ import {
62
119
  FormHiddenInput,
63
120
  } from '#components'
64
121
  import type { ColorScheme } from '@iqual/nuxt-icms/types/global'
122
+ // Shared with the server middleware that enforces the check, so the header name
123
+ // and the error code cannot drift apart. Deliberately not imported from
124
+ // `server/`: that directory carries a tsconfig Vite cannot resolve from a layer.
125
+ import {
126
+ TURNSTILE_ERROR_CODE,
127
+ TURNSTILE_TOKEN_HEADER,
128
+ } from '@iqual/nuxt-icms/utils/turnstile'
65
129
  import type {
66
130
  FormElementPreprocessor,
67
131
  SubmissionPreprocessor,
@@ -72,22 +136,77 @@ const { $texts } = useNuxtApp()
72
136
  const themedClass = useTheme('DrupalWebform')
73
137
  const { messages } = useDrupalMessages()
74
138
 
75
- const props = withDefaults(defineProps<{
76
- formId: string
77
- formElementPreprocessor?: FormElementPreprocessor
78
- submissionPreprocessor?: SubmissionPreprocessor
79
- debug?: boolean
80
- containerClass?: string
81
- formClass?: string
82
- colorScheme?: ColorScheme
83
- floatingLabels?: boolean
84
- }>(), {
85
- floatingLabels: false,
86
- })
139
+ const props = withDefaults(
140
+ defineProps<{
141
+ formId: string
142
+ formElementPreprocessor?: FormElementPreprocessor
143
+ submissionPreprocessor?: SubmissionPreprocessor
144
+ debug?: boolean
145
+ containerClass?: string
146
+ formClass?: string
147
+ colorScheme?: ColorScheme
148
+ floatingLabels?: boolean
149
+ /**
150
+ * The entity the form is embedded in, e.g. 'node'.
151
+ *
152
+ * Webform records the entity a form was submitted from, and its whole source
153
+ * entity feature set keys off it: the per-entity results and download pages,
154
+ * source entity filters, submission totals. Passing it here is what makes a
155
+ * submission show up under the entity it was made from in Drupal.
156
+ */
157
+ sourceEntityType?: string
158
+ /**
159
+ * The ID of the entity the form is embedded in.
160
+ */
161
+ sourceEntityId?: string
162
+ }>(),
163
+ {
164
+ floatingLabels: false,
165
+ sourceEntityType: undefined,
166
+ sourceEntityId: undefined,
167
+ },
168
+ )
87
169
 
88
170
  // Init a state object that will hold
89
171
  const state = ref<Record<string, any>>({})
90
172
 
173
+ // Cloudflare Turnstile. The widget only renders — and the token is only sent —
174
+ // when the project configured a site key; the server middleware enforces the
175
+ // check whenever a secret key is configured. Both default to empty, so ICMS
176
+ // installations without a Cloudflare account are unaffected.
177
+ const turnstileSiteKey = computed(
178
+ () => useRuntimeConfig().public.turnstile?.siteKey || '',
179
+ )
180
+ const turnstileEnabled = computed(() => !!turnstileSiteKey.value)
181
+ const turnstileToken = ref<string | undefined>()
182
+ const turnstileRef = ref<{ reset: () => void } | null>(null)
183
+
184
+ // Whether the form was submitted successfully. Replaces the form with a
185
+ // prominent confirmation so users notice the feedback and can not
186
+ // accidentally register twice with the same data.
187
+ const submitted = ref(false)
188
+ const containerRef = ref<HTMLElement | null>(null)
189
+
190
+ // Confirmation settings configured on the webform in Drupal. The message
191
+ // arrives as rendered HTML from the backend.
192
+ type WebformConfirmation = {
193
+ type?: string | null
194
+ title?: string | null
195
+ message?: string | null
196
+ redirectUrl?: string | null
197
+ }
198
+ const confirmation = ref<WebformConfirmation | null>(null)
199
+
200
+ // Only show the backend message for confirmation types where an editor
201
+ // explicitly chose a message display. For PAGE and NONE the backend returns
202
+ // Drupal's generic default, which is not meant for visitors.
203
+ const confirmationMessage = computed(() =>
204
+ confirmation.value?.type &&
205
+ ['MESSAGE', 'INLINE', 'MODAL'].includes(confirmation.value.type)
206
+ ? confirmation.value.message || ''
207
+ : '',
208
+ )
209
+
91
210
  // Asynchronously get the form definition
92
211
  const queryKey = `drupalWebform-${props?.formId || '0'}`
93
212
  const { data: formDefinition } = await useAsyncData(queryKey, async () => {
@@ -110,6 +229,17 @@ const formElements = computed<WebformElement[]>(() => {
110
229
  })
111
230
  })
112
231
 
232
+ // Webform renders its buttons through an actions element; the widget belongs
233
+ // right above it. Falls back to rendering after the elements when a webform
234
+ // carries no actions element, so the check is never silently skipped.
235
+ const turnstileAnchorKey = computed<string | null>(() => {
236
+ const actions = formElements.value.find(
237
+ (element) => element.metadata?.type === 'webform_actions',
238
+ )
239
+
240
+ return actions?.metadata?.key || null
241
+ })
242
+
113
243
  // Compute a dynamic Zod validation schema based on form definition
114
244
  const schema = computed(() => {
115
245
  const shape: Record<string, any> = {}
@@ -291,8 +421,7 @@ const schema = computed(() => {
291
421
  break
292
422
  }
293
423
 
294
- if (!validator)
295
- return
424
+ if (!validator) return
296
425
 
297
426
  shape[key] = validator
298
427
  })
@@ -343,12 +472,49 @@ if (props.debug) {
343
472
  )
344
473
  }
345
474
 
475
+ /**
476
+ * The message shown when the spam protection rejects a submission.
477
+ */
478
+ function spamProtectionMessage(): string {
479
+ return $texts(
480
+ 'webform.spam_protection_error',
481
+ 'The spam protection could not verify this submission. Please try again.',
482
+ )
483
+ }
484
+
485
+ /**
486
+ * Whether a failed request was rejected by the Turnstile middleware.
487
+ *
488
+ * The middleware answers with a 403 whose body carries the error code; ofetch
489
+ * exposes that body on the thrown error.
490
+ */
491
+ function isTurnstileRejection(error: unknown): boolean {
492
+ // Only the error code identifies a rejection: a 403 on its own can just as
493
+ // well be a permission problem on the Drupal side and must keep its own
494
+ // message. ofetch exposes the H3 error body as `data`, so the code the
495
+ // middleware sets in `data.code` arrives as `data.data.code`.
496
+ const candidate = error as
497
+ { data?: { code?: string; data?: { code?: string } } } | undefined
498
+
499
+ return (
500
+ candidate?.data?.data?.code === TURNSTILE_ERROR_CODE ||
501
+ candidate?.data?.code === TURNSTILE_ERROR_CODE
502
+ )
503
+ }
504
+
346
505
  async function onSubmit(event: FormSubmitEvent<Schema>) {
347
506
  const submissionData = event.data as Record<string, unknown>
348
507
  let hasShownErrorMessage = false
349
508
 
350
509
  if (props.debug) console.log('[Webform] Event data:', submissionData)
351
510
 
511
+ // The widget has not produced a token yet (still solving the challenge, or
512
+ // it expired). Submitting would be rejected by the server anyway.
513
+ if (turnstileEnabled.value && !turnstileToken.value) {
514
+ messages.value.push({ type: 'error', message: spamProtectionMessage() })
515
+ return
516
+ }
517
+
352
518
  try {
353
519
  const submissionElements = Object.entries(submissionData)
354
520
  .filter(
@@ -366,18 +532,42 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
366
532
  })
367
533
 
368
534
  if (Array.isArray(preprocessedSubmission)) {
369
- submissionElements.splice(0, submissionElements.length, ...preprocessedSubmission)
370
- }
371
- else if (preprocessedSubmission?.elements) {
372
- submissionElements.splice(0, submissionElements.length, ...preprocessedSubmission.elements)
535
+ submissionElements.splice(
536
+ 0,
537
+ submissionElements.length,
538
+ ...preprocessedSubmission,
539
+ )
540
+ } else if (preprocessedSubmission?.elements) {
541
+ submissionElements.splice(
542
+ 0,
543
+ submissionElements.length,
544
+ ...preprocessedSubmission.elements,
545
+ )
373
546
  }
374
547
 
375
548
  if (props.debug) console.log('[Webform] Payload:', submissionElements)
376
549
 
377
- const response = await useGraphqlMutation('submitWebform', {
378
- id: props.formId,
379
- elements: submissionElements,
380
- })
550
+ const response = await useGraphqlMutation(
551
+ 'submitWebform',
552
+ {
553
+ id: props.formId,
554
+ elements: submissionElements,
555
+ // Both halves or neither: a type without an ID is not a source entity.
556
+ ...(props.sourceEntityType && props.sourceEntityId
557
+ ? {
558
+ sourceEntityType: props.sourceEntityType,
559
+ sourceEntityId: props.sourceEntityId,
560
+ }
561
+ : {}),
562
+ },
563
+ turnstileEnabled.value
564
+ ? {
565
+ fetchOptions: {
566
+ headers: { [TURNSTILE_TOKEN_HEADER]: turnstileToken.value ?? '' },
567
+ },
568
+ }
569
+ : undefined,
570
+ )
381
571
 
382
572
  const result = response.data?.submitWebform
383
573
  const hasErrors = result?.errors?.length || result?.validationErrors?.length
@@ -388,28 +578,52 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
388
578
  console.error('[Webform] Submission Errors:', result.errors)
389
579
  }
390
580
 
391
- messages.value.push({
392
- type: 'error',
393
- message: $texts(
394
- 'webform.submit_error',
395
- 'The form could not be submitted. Please check your entries and try again.',
396
- ),
397
- })
398
- hasShownErrorMessage = true
581
+ const validationErrors = result?.validationErrors || []
582
+ for (const error of validationErrors) {
583
+ const message =
584
+ error?.messages?.join('\n') ||
585
+ $texts(
586
+ 'webform.submit_error',
587
+ 'The form could not be submitted. Please check your entries and try again.',
588
+ )
589
+
590
+ messages.value.push({
591
+ type: 'error',
592
+ message: error?.element ? `${error.element}: ${message}` : message,
593
+ })
594
+ }
595
+
596
+ // Fall back to the generic message when only general submission
597
+ // errors were returned.
598
+ hasShownErrorMessage = validationErrors.length > 0
399
599
 
400
600
  throw new Error()
401
601
  }
402
602
 
403
- messages.value.push({
404
- type: 'status',
405
- message: $texts(
406
- 'webform.submit_success',
407
- 'Your form has been submitted successfully.',
408
- ),
409
- })
410
-
411
603
  if (props.debug) console.log('[Webform] Success:', response)
604
+
605
+ const confirmationResult = result?.submission?.confirmation
606
+ if (
607
+ (confirmationResult?.type === 'URL' ||
608
+ confirmationResult?.type === 'URL_MESSAGE') &&
609
+ confirmationResult.redirectUrl
610
+ ) {
611
+ await navigateTo(confirmationResult.redirectUrl, {
612
+ external: /^https?:\/\//.test(confirmationResult.redirectUrl),
613
+ })
614
+ return
615
+ }
616
+
617
+ confirmation.value = confirmationResult || null
618
+ submitted.value = true
619
+ await nextTick()
620
+ containerRef.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
412
621
  } catch (error) {
622
+ if (isTurnstileRejection(error)) {
623
+ messages.value.push({ type: 'error', message: spamProtectionMessage() })
624
+ hasShownErrorMessage = true
625
+ }
626
+
413
627
  if (!hasShownErrorMessage) {
414
628
  messages.value.push({
415
629
  type: 'error',
@@ -420,6 +634,12 @@ async function onSubmit(event: FormSubmitEvent<Schema>) {
420
634
  })
421
635
  }
422
636
 
637
+ // Turnstile tokens are single use: without a reset every retry fails.
638
+ if (turnstileEnabled.value) {
639
+ turnstileToken.value = undefined
640
+ turnstileRef.value?.reset()
641
+ }
642
+
423
643
  if (props.debug) console.error('[Webform] Submission Error:', error)
424
644
  }
425
645
  }
@@ -2,12 +2,28 @@ mutation submitWebform(
2
2
  $id: String!
3
3
  $elements: [WebformSubmissionElement]
4
4
  $files: [WebformSubmissionFile]
5
+ $sourceEntityType: String
6
+ $sourceEntityId: ID
5
7
  ) {
6
- submitWebform(id: $id, elements: $elements, files: $files) {
8
+ submitWebform(
9
+ id: $id
10
+ elements: $elements
11
+ files: $files
12
+ sourceEntityType: $sourceEntityType
13
+ sourceEntityId: $sourceEntityId
14
+ ) {
7
15
  errors
8
16
  validationErrors {
9
17
  element
10
18
  messages
11
19
  }
20
+ submission {
21
+ confirmation {
22
+ type
23
+ title
24
+ message
25
+ redirectUrl
26
+ }
27
+ }
12
28
  }
13
29
  }
@@ -8,10 +8,7 @@ useFormDefaultValue(model, props.formElement.metadata?.defaultValue)
8
8
  </script>
9
9
 
10
10
  <template>
11
- <UFormField
12
- :name="formElement?.metadata?.key"
13
- class="hidden"
14
- >
11
+ <UFormField :name="formElement?.metadata?.key" class="hidden">
15
12
  <input
16
13
  type="hidden"
17
14
  :name="formElement?.metadata?.key"
@@ -362,8 +362,8 @@ const hasSelectedValue = computed(() => {
362
362
  >
363
363
  <div :class="themedClass('selectWrapper', 'group relative')">
364
364
  <USelect
365
- v-model="model"
366
365
  :id="formElement.metadata.key"
366
+ v-model="model"
367
367
  :items="formattedOptions"
368
368
  :multiple="Boolean(formElement.metadata.multiple)"
369
369
  :placeholder="
@@ -19,7 +19,7 @@ const colorScheme = inject<ColorScheme>('colorScheme')
19
19
  as="button"
20
20
  type="submit"
21
21
  :label="formElement?.submitLabel || $texts('webform.submit', 'Submit')"
22
- buttonStyle="focus"
23
- :colorScheme="colorScheme"
22
+ button-style="focus"
23
+ :color-scheme="colorScheme"
24
24
  />
25
25
  </template>
@@ -249,8 +249,8 @@ const floatingLabelText = tv({
249
249
  >
250
250
  <div :class="themedClass('textAreaWrapper', 'relative')">
251
251
  <UTextarea
252
- v-model="model"
253
252
  :id="formElement.metadata.key"
253
+ v-model="model"
254
254
  :placeholder="
255
255
  floatingLabels
256
256
  ? formElement.placeholder || ' '
@@ -260,12 +260,12 @@ const floatingLabelText = tv({
260
260
  :minlength="formElement.minlength"
261
261
  :pattern="formElement.pattern?.rule"
262
262
  :data-floating="floatingLabels"
263
- @focus="isTextAreaFocused = true"
264
- @blur="isTextAreaFocused = false"
265
263
  :ui="{
266
264
  root: themedClass('textAreaRoot', 'relative flex items-center'),
267
265
  base: textAreaBase({ colorScheme }),
268
266
  }"
267
+ @focus="isTextAreaFocused = true"
268
+ @blur="isTextAreaFocused = false"
269
269
  />
270
270
  <label
271
271
  v-if="floatingLabels"
@@ -179,8 +179,8 @@ const floatingLabelText = tv({
179
179
  }"
180
180
  >
181
181
  <UInput
182
- v-model="model"
183
182
  :id="formElement.metadata.key"
183
+ v-model="model"
184
184
  :type="formElement.metadata.type"
185
185
  :placeholder="
186
186
  floatingLabels
@@ -179,8 +179,8 @@ const floatingLabelText = tv({
179
179
  }"
180
180
  >
181
181
  <UInput
182
- v-model="model"
183
182
  :id="formElement.metadata.key"
183
+ v-model="model"
184
184
  :type="formElement.metadata.type"
185
185
  :placeholder="
186
186
  floatingLabels
@@ -179,8 +179,8 @@ const floatingLabelText = tv({
179
179
  }"
180
180
  >
181
181
  <UInput
182
- v-model="model"
183
182
  :id="formElement.metadata.key"
183
+ v-model="model"
184
184
  :type="formElement.metadata.type"
185
185
  :placeholder="
186
186
  floatingLabels
@@ -37,5 +37,5 @@ const markupText = tv({
37
37
  </script>
38
38
 
39
39
  <template>
40
- <div :class="markupText({ colorScheme })" v-html="formElement.markup"></div>
40
+ <div :class="markupText({ colorScheme })" v-html="formElement.markup" />
41
41
  </template>
@@ -3,23 +3,21 @@ type ModelRef<T> = {
3
3
  }
4
4
 
5
5
  function parseFormDefaultValue(rawDefaultValue?: string | null): unknown {
6
- if (!rawDefaultValue)
7
- return undefined
6
+ if (!rawDefaultValue) return undefined
8
7
 
9
8
  try {
10
9
  return JSON.parse(rawDefaultValue)
11
- }
12
- catch {
10
+ } catch {
13
11
  return rawDefaultValue
14
12
  }
15
13
  }
16
14
 
17
15
  function isEmptyModelValue(value: unknown): boolean {
18
16
  return (
19
- value === undefined
20
- || value === null
21
- || value === ''
22
- || (Array.isArray(value) && value.length === 0)
17
+ value === undefined ||
18
+ value === null ||
19
+ value === '' ||
20
+ (Array.isArray(value) && value.length === 0)
23
21
  )
24
22
  }
25
23
 
@@ -29,9 +27,7 @@ export function useFormDefaultValue<T>(
29
27
  ): void {
30
28
  const parsedDefaultValue = parseFormDefaultValue(rawDefaultValue)
31
29
 
32
- if (parsedDefaultValue === undefined)
33
- return
30
+ if (parsedDefaultValue === undefined) return
34
31
 
35
- if (isEmptyModelValue(model.value))
36
- model.value = parsedDefaultValue as T
37
- }
32
+ if (isEmptyModelValue(model.value)) model.value = parsedDefaultValue as T
33
+ }
@@ -1,6 +1,8 @@
1
1
  import type { MetadataFragment } from '#graphql-operations'
2
2
 
3
- export function useFormElement<T extends { metadata: MetadataFragment }>(formElement: T) {
3
+ export function useFormElement<T extends { metadata: MetadataFragment }>(
4
+ formElement: T,
5
+ ) {
4
6
  const cleanDescription = computed(() => {
5
7
  return formElement.metadata?.description?.replace(/<[^>]*>/g, '') ?? ''
6
8
  })
@@ -27,6 +29,6 @@ export function useFormElement<T extends { metadata: MetadataFragment }>(formEle
27
29
 
28
30
  return {
29
31
  cleanDescription,
30
- formattedOptions
32
+ formattedOptions,
31
33
  }
32
- }
34
+ }
@@ -6,7 +6,7 @@ import type {
6
6
  WebformElementCheckboxesFragment,
7
7
  WebformElementMarkupBaseFragment,
8
8
  WebformElementManagedFileFragment,
9
- WebformElementActionsFragment
9
+ WebformElementActionsFragment,
10
10
  } from '#graphql-operations'
11
11
 
12
12
  type Metadata<T> = {
@@ -51,7 +51,7 @@ export interface FormElementPreprocessorContext {
51
51
 
52
52
  export type FormElementPreprocessor = (
53
53
  context: FormElementPreprocessorContext,
54
- ) => void | WebformElement
54
+ ) => WebformElement | undefined
55
55
 
56
56
  export interface SubmissionElement {
57
57
  element: string
@@ -66,4 +66,4 @@ export interface SubmissionPreprocessorContext {
66
66
 
67
67
  export type SubmissionPreprocessor = (
68
68
  context: SubmissionPreprocessorContext,
69
- ) => void | SubmissionElement[] | { elements?: SubmissionElement[] }
69
+ ) => SubmissionElement[] | { elements?: SubmissionElement[] } | undefined
@@ -14,4 +14,8 @@ fragment mediaImage on MediaImage {
14
14
  y: yNormalized
15
15
  }
16
16
  }
17
+ dimensions: fieldMediaImage {
18
+ width
19
+ height
20
+ }
17
21
  }