@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.
- package/README.md +86 -0
- package/__tests__/anchors.spec.ts +43 -0
- package/__tests__/contract/blokkliComponents.spec.ts +59 -0
- package/__tests__/contract/componentFragments.spec.ts +24 -0
- package/__tests__/contract/languages.spec.ts +63 -0
- package/__tests__/contract/project.ts +144 -0
- package/__tests__/hreflang.spec.ts +39 -0
- package/{tests → __tests__}/pages.spec.ts +14 -5
- package/app.config.ts +15 -0
- package/app.vue +3 -21
- package/assets/css/backend.css +56 -0
- package/assets/css/base/base.css +2 -2
- package/assets/css/base/typography.css +2 -2
- package/assets/css/ckeditor.css +3 -4
- package/assets/css/components/decorations.css +1 -1
- package/assets/css/components/editor.css +1 -1
- package/assets/css/components/grid-container.css +19 -19
- package/assets/css/fe-fixes.css +6 -12
- package/assets/css/theme/colors/color-primary.css +33 -11
- package/assets/css/theme/colors/color-secondary.css +33 -11
- package/assets/css/utilities/utilities.css +1 -1
- package/assets/symbols/anchor.svg +3 -0
- package/blokkli.editAdapter.ts +56 -0
- package/bundles.ts +6 -6
- package/cache/__tests__/pageCache.spec.ts +540 -0
- package/cache/cacheTagRegistry.ts +124 -0
- package/cache/enabledForRequest.ts +21 -0
- package/cache/multiCache.serverOptions.ts +58 -0
- package/cache/pageCache.ts +360 -0
- package/components/Blokkli/Anchor/icon.svg +3 -0
- package/components/Blokkli/Anchor/index.vue +13 -0
- package/components/Blokkli/AnchorNavigation/icon.svg +8 -0
- package/components/Blokkli/AnchorNavigation/index.vue +9 -0
- package/components/Blokkli/Card/Element/index.vue +0 -1
- package/components/Blokkli/Collapsible/Element/index.vue +5 -0
- package/components/Blokkli/FAQ/Element/index.vue +0 -1
- package/components/Blokkli/Fragment/Separator/icon.svg +4 -0
- package/components/Blokkli/Fragment/Separator/index.vue +87 -0
- package/components/Blokkli/Gallery/index.vue +1 -3
- package/components/Blokkli/IconText/Element/index.vue +0 -1
- package/components/Blokkli/Introduction/index.vue +0 -1
- package/components/Blokkli/Listing/icon.svg +9 -0
- package/components/Blokkli/Listing/index.vue +24 -0
- package/components/Blokkli/Logo/Element/index.vue +0 -1
- package/components/Blokkli/Table/index.vue +0 -1
- package/components/Blokkli/Team/Element/index.vue +0 -1
- package/components/Blokkli/Team/Layout/index.vue +0 -1
- package/components/Blokkli/Testimonial/Element/index.vue +0 -1
- package/components/Blokkli/Text/index.vue +0 -1
- package/components/DesignSystem/Atoms/Button.vue +3 -0
- package/components/DesignSystem/Atoms/GridRow.vue +3 -1
- package/components/DesignSystem/Molecules/ButtonGroup.vue +5 -4
- package/components/DesignSystem/Molecules/IconText.vue +6 -6
- package/components/DesignSystem/Molecules/MediaLightbox.vue +8 -8
- package/components/DesignSystem/Molecules/MediaSlider.vue +9 -11
- package/components/DesignSystem/Molecules/Partnership.vue +7 -4
- package/components/DesignSystem/Molecules/Table.vue +35 -19
- package/components/DesignSystem/Molecules/TitleComposition.vue +46 -15
- package/components/DesignSystem/Molecules/iFrame.vue +2 -2
- package/components/DesignSystem/Organisms/CallToAction.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFeature.vue +8 -7
- package/components/DesignSystem/Organisms/CallToActionFullwidth.vue +9 -8
- package/components/DesignSystem/Organisms/ContentBlock.vue +26 -16
- package/components/DesignSystem/Organisms/FacetedListing.vue +38 -17
- package/components/DesignSystem/Organisms/FactCard.vue +6 -7
- package/components/DesignSystem/Organisms/FeatureCard.vue +14 -14
- package/components/DesignSystem/Organisms/FlipTeaser.vue +19 -19
- package/components/DesignSystem/Organisms/HeaderHero.vue +11 -11
- package/components/DesignSystem/Organisms/HeaderTextImage.vue +10 -10
- package/components/DesignSystem/Organisms/LinkList.vue +20 -12
- package/components/DesignSystem/Organisms/LogoElement.vue +7 -4
- package/components/DesignSystem/Organisms/Map.vue +1 -1
- package/components/DesignSystem/Organisms/SearchResults.vue +15 -6
- package/components/DesignSystem/Organisms/SpotlightCard.vue +18 -18
- package/components/DesignSystem/Organisms/TeamElement.vue +19 -18
- package/components/DesignSystem/Organisms/TeaserCard.vue +16 -17
- package/components/DesignSystem/Organisms/TeaserSimple.vue +4 -31
- package/components/DesignSystem/Organisms/Testimonial.vue +13 -13
- package/components/DesignSystem/Organisms/TextCard.vue +10 -10
- package/components/DesignSystem/Organisms/TimelineElement.vue +20 -19
- package/components/DesignSystem/Organisms/TwoColumnTextElement.vue +5 -5
- package/components/DesignSystem/README.md +59 -40
- package/components/Drupal/Breadcrumb/index.vue +25 -12
- package/components/Drupal/Messages/index.vue +9 -4
- package/components/Drupal/Webform/index.vue +271 -51
- package/components/Drupal/Webform/mutation.graphql +17 -1
- package/components/Form/HiddenInput.vue +1 -4
- package/components/Form/SelectInput.vue +1 -1
- package/components/Form/SubmitButton.vue +2 -2
- package/components/Form/TextAreaInput.vue +3 -3
- package/components/Form/TextEmailInput.vue +1 -1
- package/components/Form/TextInput.vue +1 -1
- package/components/Form/TextPhoneInput.vue +1 -1
- package/components/Form/WebformMarkup.vue +1 -1
- package/components/Form/composables/useFormDefaultValue.ts +9 -13
- package/components/Form/composables/useFormElement.ts +5 -3
- package/components/Form/utils/types.ts +3 -3
- package/components/Media/Image/fragment.graphql +4 -0
- package/components/Media/Image/index.vue +27 -17
- package/components/Media/RemoteVideo/index.vue +3 -3
- package/components/Media/Video/index.vue +1 -1
- package/components/Node/Icms/Page/SearchResult/fragment.graphql +1 -1
- package/components/Node/Icms/Page/SearchResult/index.vue +2 -2
- package/components/Node/Icms/Page/Teaser/index.vue +1 -1
- package/components/Node/Icms/Page/index.vue +8 -2
- package/components/Paragraph/Anchor/fragment.graphql +4 -0
- package/components/Paragraph/Anchor/index.vue +48 -0
- package/components/Paragraph/AnchorNavigation/fragment.graphql +3 -0
- package/components/Paragraph/AnchorNavigation/index.vue +154 -0
- package/components/Paragraph/BlokkliFragment/fragment.graphql +5 -0
- package/components/Paragraph/Button/Element/fragment.graphql +1 -1
- package/components/Paragraph/Button/Element/index.vue +13 -8
- package/components/Paragraph/Card/Element/index.vue +1 -1
- package/components/Paragraph/Card/Layout/index.vue +9 -9
- package/components/Paragraph/Collapsible/Element/index.vue +2 -2
- package/components/Paragraph/Collapsible/Layout/index.vue +19 -18
- package/components/Paragraph/Contact/index.vue +17 -17
- package/components/Paragraph/Downloads/fragment.graphql +1 -1
- package/components/Paragraph/Downloads/index.vue +13 -13
- package/components/Paragraph/FAQ/Element/index.vue +4 -3
- package/components/Paragraph/FAQ/Layout/index.vue +42 -21
- package/components/Paragraph/FromLibrary/fragment.graphql +17 -0
- package/components/Paragraph/Gallery/index.vue +14 -14
- package/components/Paragraph/HeroImage/index.vue +26 -19
- package/components/Paragraph/IconText/Element/index.vue +4 -4
- package/components/Paragraph/IconText/Layout/index.vue +12 -12
- package/components/Paragraph/Introduction/index.vue +17 -17
- package/components/Paragraph/LinkLists/Element/fragment.graphql +1 -1
- package/components/Paragraph/LinkLists/Element/index.vue +5 -5
- package/components/Paragraph/LinkLists/Layout/index.vue +9 -9
- package/components/Paragraph/Listing/fragment.graphql +14 -0
- package/components/Paragraph/Listing/index.vue +91 -0
- package/components/Paragraph/Logo/Element/fragment.graphql +1 -1
- package/components/Paragraph/Logo/Element/index.vue +7 -4
- package/components/Paragraph/Media/index.vue +23 -21
- package/components/Paragraph/Partnerships/Element/fragment.graphql +1 -1
- package/components/Paragraph/Partnerships/Element/index.vue +10 -7
- package/components/Paragraph/Partnerships/Layout/index.vue +13 -13
- package/components/Paragraph/SingleTestimonial/index.vue +13 -13
- package/components/Paragraph/Table/index.vue +12 -12
- package/components/Paragraph/Team/Element/index.vue +16 -14
- package/components/Paragraph/Team/Layout/index.vue +9 -9
- package/components/Paragraph/Teasers/index.vue +14 -17
- package/components/Paragraph/Testimonial/Element/index.vue +19 -18
- package/components/Paragraph/Testimonial/Layout/index.vue +24 -18
- package/components/Paragraph/Text/fragment.graphql +1 -1
- package/components/Paragraph/Text/index.vue +8 -8
- package/components/Paragraph/TextMedia/index.vue +22 -22
- package/components/Paragraph/Timeline/Element/index.vue +19 -19
- package/components/Paragraph/Timeline/Layout/index.vue +51 -20
- package/components/Paragraph/TwoColumnText/Element/index.vue +13 -13
- package/components/Paragraph/TwoColumnText/Layout/index.vue +1 -1
- package/components/Paragraph/iFrame/index.vue +8 -8
- package/components/Search/Facets/FacetDropdownUI.vue +25 -31
- package/components/Search/Facets/FacetedSearchUI.vue +72 -48
- package/components/Search/Facets/fragment.graphql +20 -15
- package/components/Section/Footer/index.vue +55 -32
- package/components/Section/Header/Buttons.vue +4 -5
- package/components/Section/Header/DesktopMainMenu.vue +5 -4
- package/components/Section/Header/DesktopMainMenuDropdown.vue +5 -5
- package/components/Section/Header/LanguageDropdown.vue +2 -3
- package/components/Section/Header/Logo.vue +3 -3
- package/components/Section/Header/MainButtons.vue +4 -5
- package/components/Section/Header/MetaButtons.vue +5 -6
- package/components/Section/Header/MetaMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenu.vue +1 -1
- package/components/Section/Header/MobileMainMenuItem.vue +2 -2
- package/components/Section/Header/index.vue +6 -13
- package/composables/__tests__/useImageSources.spec.ts +91 -0
- package/composables/breadcrumb.ts +12 -7
- package/composables/useAccordionScroll.ts +12 -4
- package/composables/useAllowedFragments.ts +14 -0
- package/composables/useAnchors.ts +48 -0
- package/composables/useChildTitleWeight.ts +1 -1
- package/composables/useCurrentLangcodeEnum.ts +1 -1
- package/composables/useCurrentNodeId.ts +8 -0
- package/composables/useDrupalUrl.ts +15 -0
- package/composables/useEnrichFacetLabels.ts +8 -6
- package/composables/useEntityRegistry.ts +3 -3
- package/composables/useGlobalConfig.ts +1 -2
- package/composables/useGraphqlCacheability.ts +12 -1
- package/composables/useHreflangLinks.ts +54 -0
- package/composables/useImageSources.ts +70 -28
- package/composables/useInitData.ts +1 -2
- package/composables/useIsHomepage.ts +1 -1
- package/composables/useLinkUrl.ts +30 -0
- package/composables/useMediaLightbox.ts +62 -50
- package/composables/useMenu.ts +1 -3
- package/composables/useMessageBus.ts +14 -7
- package/composables/useMobileMenuOverlayBus.ts +1 -1
- package/composables/useNavigationOffset.ts +49 -6
- package/composables/usePageCache.ts +33 -0
- package/composables/useSiteShell.ts +11 -0
- package/composables/useTheme.ts +17 -13
- package/config/__tests__/stubs/nuxt-multi-cache-config.ts +2 -0
- package/config/blokkli.ts +7 -13
- package/config/easyTexts.ts +1 -1
- package/config/graphqlMiddleware.ts +11 -4
- package/config/index.ts +5 -2
- package/config/languageNegotiation.ts +1 -1
- package/config/multiCache.ts +6 -1
- package/config/postcss-drupal/postcss.config.cjs +2 -2
- package/config/runtimeConfig.ts +7 -2
- package/config/svgIconSprite.ts +1 -2
- package/config/vuepal.ts +16 -18
- package/error.vue +1 -22
- package/layouts/default.vue +2 -6
- package/layouts/error.vue +7 -15
- package/modules/blokkliFragments.ts +76 -0
- package/modules/dynamicEntityComponentResolver.ts +47 -47
- package/modules/dynamicParagraphResolver.ts +43 -8
- package/modules/pageCache.ts +106 -0
- package/nuxt.config.ts +62 -33
- package/package.json +20 -19
- package/pages/[...slug]/index.vue +25 -4
- package/pages/index.vue +3 -3
- package/pages/node/[nid]/index.vue +2 -3
- package/patches/@blokkli+editor+2.0.0-alpha.13.patch +15 -1
- package/plugins/currentNode.ts +17 -0
- package/plugins/graphqlMiddleware.ts +10 -2
- package/plugins/initData.ts +15 -7
- package/plugins/registerNodeIcmsPageComponents.ts +6 -2
- package/plugins/siteShell.client.ts +94 -0
- package/queries/facetTermLabels.graphql +1 -5
- package/queries/fragment.linkUrl.graphql +12 -0
- package/queries/query.contentSearch.graphql +17 -2
- package/queries/query.initData.graphql +1 -1
- package/scripts/create-shade.ts +13 -4
- package/scripts/update-icms.ts +3 -1
- package/server/__tests__/graphqlMiddleware.serverOptions.spec.ts +2 -2
- package/server/__tests__/turnstile.spec.ts +119 -0
- package/server/api/icms/blokkli/last-changed.get.ts +24 -0
- package/server/api/icon/[...params].ts +3 -9
- package/server/graphqlMiddleware.serverOptions.ts +9 -8
- package/server/helpers/index.ts +0 -1
- package/server/middleware/turnstile.ts +51 -0
- package/server/plugins/frameAncestors.ts +20 -0
- package/server/plugins/pageCache.ts +59 -0
- package/server/utils/__tests__/accessDeniedHandover.spec.ts +49 -0
- package/server/utils/__tests__/backendRequestHeaders.spec.ts +115 -0
- package/server/utils/accessDeniedHandover.ts +48 -0
- package/server/utils/backendRequestHeaders.ts +51 -0
- package/server/utils/cacheability.ts +4 -4
- package/shared/__tests__/blokkliState.spec.ts +54 -0
- package/shared/__tests__/csp.spec.ts +53 -0
- package/shared/__tests__/drupalUrl.spec.ts +50 -0
- package/shared/blokkliState.ts +40 -0
- package/shared/csp.ts +47 -0
- package/shared/drupalUrl.ts +30 -0
- package/tailwind.config.ts +0 -5
- package/types/breadcrumb.ts +22 -0
- package/types/global.ts +15 -14
- package/types/langcode.ts +16 -0
- package/types/layerConfig.ts +84 -0
- package/utils/pageCache.ts +95 -0
- package/utils/turnstile.ts +35 -0
- package/composables/useWebformEnrichment.ts +0 -68
- package/server/__tests__/graphqlMiddleware.ts +0 -41
- package/server/multiCache.serverOptions.ts +0 -32
- 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
|
-
|
|
24
|
-
v-
|
|
25
|
-
|
|
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
|
-
|
|
32
|
-
v-model="
|
|
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
|
-
:
|
|
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(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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(
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
message
|
|
394
|
-
'
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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(
|
|
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
|
-
|
|
23
|
-
:
|
|
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"
|
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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 }>(
|
|
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
|
-
) =>
|
|
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
|
-
) =>
|
|
69
|
+
) => SubmissionElement[] | { elements?: SubmissionElement[] } | undefined
|