@duffcloudservices/site-forms 0.1.1 → 0.1.2

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/src/DcsForm.vue CHANGED
@@ -1,303 +1,303 @@
1
- <script setup lang="ts">
2
- import { computed, onMounted, ref, watch } from 'vue'
3
- import type {
4
- DcsFormSubmitError,
5
- DcsFormSubmitSuccess,
6
- PortalFormDefinition,
7
- PortalFormField,
8
- } from './types'
9
- import { useDcsForm } from './composables/useDcsForm'
10
- import { submitFormValues } from './composables/useFormSubmission'
11
- import { warnIfInvalid } from './schema/validate'
12
- import { loadFormDefinitions } from './loaders/yaml'
13
-
14
- import DcsFormText from './fields/DcsFormText.vue'
15
- import DcsFormTextarea from './fields/DcsFormTextarea.vue'
16
- import DcsFormSelect from './fields/DcsFormSelect.vue'
17
- import DcsFormRadio from './fields/DcsFormRadio.vue'
18
- import DcsFormCheckboxGroup from './fields/DcsFormCheckboxGroup.vue'
19
- import DcsFormCheckbox from './fields/DcsFormCheckbox.vue'
20
- import DcsFormDate from './fields/DcsFormDate.vue'
21
- import DcsFormFile from './fields/DcsFormFile.vue'
22
- import DcsFormHidden from './fields/DcsFormHidden.vue'
23
- import DcsFormSection from './fields/DcsFormSection.vue'
24
- import DcsFormHtmlBlock from './fields/DcsFormHtmlBlock.vue'
25
-
26
- interface Props {
27
- /** Kebab-case form id; matches `.dcs/forms/<formId>.yaml`. */
28
- formId: string
29
- /** Site slug used for the submission endpoint path. */
30
- siteSlug?: string
31
- /** Override the loaded definition (used by the portal preview iframe). */
32
- definitionOverride?: PortalFormDefinition
33
- /** Override the API base URL; defaults to `VITE_DCS_PUBLIC_API`. */
34
- apiBase?: string
35
- /** Optional captcha token, attached to the submission payload. */
36
- captchaToken?: string
37
- /**
38
- * Optional override for the YAML modules map. Mostly for tests; in
39
- * real apps the build-time `import.meta.glob` call below is used.
40
- */
41
- formsModules?: Record<string, unknown>
42
- }
43
-
44
- const props = defineProps<Props>()
45
-
46
- const emit = defineEmits<{
47
- 'submit-success': [event: DcsFormSubmitSuccess]
48
- 'submit-error': [event: DcsFormSubmitError]
49
- 'validation-error': [errors: Record<string, string | undefined>]
50
- }>()
51
-
52
- // Eager glob — Vite inlines every site form at build time. Customer
53
- // sites should configure `vite-plugin-yaml` so YAML is parsed to an
54
- // object; without it the loader falls back to parsing raw strings.
55
- const eagerFormsModules = (import.meta as unknown as {
56
- glob: (
57
- pattern: string,
58
- opts: { eager: true; import: string; query?: string },
59
- ) => Record<string, unknown>
60
- }).glob('/.dcs/forms/*.yaml', { eager: true, import: 'default' })
61
-
62
- const definitions = computed(() =>
63
- loadFormDefinitions(props.formsModules ?? eagerFormsModules),
64
- )
65
-
66
- const definition = computed<PortalFormDefinition | null>(() => {
67
- if (props.definitionOverride) return props.definitionOverride
68
- return definitions.value[props.formId] ?? null
69
- })
70
-
71
- const isDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV
72
-
73
- watch(
74
- definition,
75
- (def) => {
76
- if (def) warnIfInvalid(props.formId, def, isDev)
77
- },
78
- { immediate: true },
79
- )
80
-
81
- // We always call useDcsForm with *some* definition so hooks render
82
- // stably; if the form is missing we'll show an error state instead.
83
- const safeDefinition = computed<PortalFormDefinition>(
84
- () =>
85
- definition.value ?? {
86
- formId: props.formId,
87
- title: '',
88
- submission: { kind: 'lead' },
89
- fields: [],
90
- },
91
- )
92
-
93
- const form = useDcsForm({ definition: safeDefinition.value })
94
-
95
- // If the resolved definition changes (e.g. preview iframe updates),
96
- // re-create derived state by resetting.
97
- watch(
98
- () => safeDefinition.value,
99
- () => form.reset(),
100
- )
101
-
102
- const apiBase = computed(
103
- () =>
104
- props.apiBase ??
105
- (import.meta as unknown as { env?: { VITE_DCS_PUBLIC_API?: string } }).env
106
- ?.VITE_DCS_PUBLIC_API ??
107
- '',
108
- )
109
-
110
- const resolvedSiteSlug = computed(
111
- () =>
112
- props.siteSlug ??
113
- (import.meta as unknown as { env?: { VITE_DCS_SITE_SLUG?: string } }).env
114
- ?.VITE_DCS_SITE_SLUG ??
115
- '',
116
- )
117
-
118
- const submitLabel = computed(
119
- () => safeDefinition.value.submitLabel ?? 'Send',
120
- )
121
-
122
- function fieldComponent(field: PortalFormField) {
123
- switch (field.type) {
124
- case 'text':
125
- case 'email':
126
- case 'tel':
127
- return DcsFormText
128
- case 'textarea':
129
- return DcsFormTextarea
130
- case 'select':
131
- case 'multiselect':
132
- return DcsFormSelect
133
- case 'radio':
134
- return DcsFormRadio
135
- case 'checkbox-group':
136
- return DcsFormCheckboxGroup
137
- case 'checkbox':
138
- return DcsFormCheckbox
139
- case 'date':
140
- return DcsFormDate
141
- case 'file':
142
- return DcsFormFile
143
- case 'hidden':
144
- return DcsFormHidden
145
- case 'section-heading':
146
- return DcsFormSection
147
- case 'html-block':
148
- return DcsFormHtmlBlock
149
- default:
150
- return DcsFormText
151
- }
152
- }
153
-
154
- const formEl = ref<HTMLFormElement | null>(null)
155
-
156
- async function onSubmit(e: Event): Promise<void> {
157
- e.preventDefault()
158
- if (!definition.value) return
159
- const ok = form.validateAll()
160
- if (!ok) {
161
- emit('validation-error', form.errors.value)
162
- return
163
- }
164
- form.submitting.value = true
165
- form.submitError.value = null
166
- const payload = {
167
- formId: props.formId,
168
- values: form.collectSubmissionValues(),
169
- captchaToken: props.captchaToken,
170
- }
171
- try {
172
- const result = await submitFormValues({
173
- apiBase: apiBase.value,
174
- siteSlug: resolvedSiteSlug.value,
175
- payload,
176
- })
177
- form.submitted.value = true
178
- emit('submit-success', result)
179
- } catch (err) {
180
- const e2 = err as DcsFormSubmitError
181
- form.submitError.value = e2.error?.message ?? 'Submission failed'
182
- emit('submit-error', e2)
183
- } finally {
184
- form.submitting.value = false
185
- }
186
- }
187
-
188
- onMounted(() => {
189
- if (!definition.value) {
190
- // eslint-disable-next-line no-console
191
- console.warn(
192
- `[@duffcloudservices/site-forms] No form definition found for "${props.formId}". ` +
193
- `Expected a YAML at .dcs/forms/${props.formId}.yaml. ` +
194
- `In customer-site repos the YAML lives above the Vite root, so the ` +
195
- `internal auto-glob will not find it — pass formsModules explicitly: ` +
196
- `<DcsForm form-id="${props.formId}" :forms-modules="dcsFormsModules" />. ` +
197
- `See @duffcloudservices/site-forms README "Vite setup" section.`,
198
- )
199
- }
200
- })
201
- </script>
202
-
203
- <template>
204
- <div v-if="!definition" class="dcs-form dcs-form--missing" :data-form-key="formId">
205
- <slot name="missing" :form-id="formId">
206
- <p>Form &ldquo;{{ formId }}&rdquo; is not configured.</p>
207
- </slot>
208
- </div>
209
-
210
- <div v-else-if="form.submitted.value" class="dcs-form dcs-form--success" :data-form-key="formId">
211
- <slot name="success" :definition="definition">
212
- <p>{{ definition.successMessage ?? 'Thanks — we received your message.' }}</p>
213
- </slot>
214
- </div>
215
-
216
- <form
217
- v-else
218
- ref="formEl"
219
- class="dcs-form"
220
- :data-form-key="formId"
221
- novalidate
222
- @submit="onSubmit"
223
- >
224
- <slot name="header" :definition="definition">
225
- <header class="dcs-form__header">
226
- <h2 class="dcs-form__title">{{ definition.title }}</h2>
227
- <p v-if="definition.description" class="dcs-form__description">
228
- {{ definition.description }}
229
- </p>
230
- </header>
231
- </slot>
232
-
233
- <div v-if="form.steps.value" class="dcs-form__progress" aria-live="polite">
234
- <slot
235
- name="progress"
236
- :current="form.currentStepIndex.value"
237
- :total="form.steps.value.length"
238
- :step="form.currentStep.value"
239
- >
240
- <p>
241
- Step {{ form.currentStepIndex.value + 1 }} of
242
- {{ form.steps.value.length }}
243
- <template v-if="form.currentStep.value">
244
- — {{ form.currentStep.value.title }}
245
- </template>
246
- </p>
247
- </slot>
248
- </div>
249
-
250
- <div class="dcs-form__fields">
251
- <component
252
- :is="fieldComponent(field)"
253
- v-for="field in form.visibleFields.value"
254
- :key="field.id"
255
- :field="field"
256
- :model-value="form.values[field.id]"
257
- :error="form.errors.value[field.id]"
258
- @update:model-value="(v: unknown) => form.setValue(field.id, v)"
259
- @blur="form.touch(field.id)"
260
- />
261
- </div>
262
-
263
- <div v-if="form.submitError.value" class="dcs-form__submit-error" role="alert">
264
- {{ form.submitError.value }}
265
- </div>
266
-
267
- <div class="dcs-form__actions">
268
- <slot
269
- name="actions"
270
- :is-first-step="form.isFirstStep.value"
271
- :is-last-step="form.isLastStep.value"
272
- :submitting="form.submitting.value"
273
- :prev="form.prev"
274
- :next="form.next"
275
- >
276
- <button
277
- v-if="form.steps.value && !form.isFirstStep.value"
278
- type="button"
279
- class="dcs-form__btn dcs-form__btn--prev"
280
- @click="form.prev()"
281
- >
282
- Previous
283
- </button>
284
- <button
285
- v-if="form.steps.value && !form.isLastStep.value"
286
- type="button"
287
- class="dcs-form__btn dcs-form__btn--next"
288
- @click="form.next()"
289
- >
290
- Next
291
- </button>
292
- <button
293
- v-if="!form.steps.value || form.isLastStep.value"
294
- type="submit"
295
- class="dcs-form__btn dcs-form__btn--submit"
296
- :disabled="form.submitting.value"
297
- >
298
- {{ form.submitting.value ? 'Sending…' : submitLabel }}
299
- </button>
300
- </slot>
301
- </div>
302
- </form>
303
- </template>
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import type {
4
+ DcsFormSubmitError,
5
+ DcsFormSubmitSuccess,
6
+ PortalFormDefinition,
7
+ PortalFormField,
8
+ } from './types'
9
+ import { useDcsForm } from './composables/useDcsForm'
10
+ import { submitFormValues } from './composables/useFormSubmission'
11
+ import { warnIfInvalid } from './schema/validate'
12
+ import { loadFormDefinitions } from './loaders/yaml'
13
+
14
+ import DcsFormText from './fields/DcsFormText.vue'
15
+ import DcsFormTextarea from './fields/DcsFormTextarea.vue'
16
+ import DcsFormSelect from './fields/DcsFormSelect.vue'
17
+ import DcsFormRadio from './fields/DcsFormRadio.vue'
18
+ import DcsFormCheckboxGroup from './fields/DcsFormCheckboxGroup.vue'
19
+ import DcsFormCheckbox from './fields/DcsFormCheckbox.vue'
20
+ import DcsFormDate from './fields/DcsFormDate.vue'
21
+ import DcsFormFile from './fields/DcsFormFile.vue'
22
+ import DcsFormHidden from './fields/DcsFormHidden.vue'
23
+ import DcsFormSection from './fields/DcsFormSection.vue'
24
+ import DcsFormHtmlBlock from './fields/DcsFormHtmlBlock.vue'
25
+
26
+ interface Props {
27
+ /** Kebab-case form id; matches `.dcs/forms/<formId>.yaml`. */
28
+ formId: string
29
+ /** Site slug used for the submission endpoint path. */
30
+ siteSlug?: string
31
+ /** Override the loaded definition (used by the portal preview iframe). */
32
+ definitionOverride?: PortalFormDefinition
33
+ /** Override the API base URL; defaults to `VITE_DCS_PUBLIC_API`. */
34
+ apiBase?: string
35
+ /** Optional captcha token, attached to the submission payload. */
36
+ captchaToken?: string
37
+ /**
38
+ * Optional override for the YAML modules map. Mostly for tests; in
39
+ * real apps the build-time `import.meta.glob` call below is used.
40
+ */
41
+ formsModules?: Record<string, unknown>
42
+ }
43
+
44
+ const props = defineProps<Props>()
45
+
46
+ const emit = defineEmits<{
47
+ 'submit-success': [event: DcsFormSubmitSuccess]
48
+ 'submit-error': [event: DcsFormSubmitError]
49
+ 'validation-error': [errors: Record<string, string | undefined>]
50
+ }>()
51
+
52
+ // Eager glob — Vite inlines every site form at build time. Customer
53
+ // sites should configure `vite-plugin-yaml` so YAML is parsed to an
54
+ // object; without it the loader falls back to parsing raw strings.
55
+ const eagerFormsModules = (import.meta as unknown as {
56
+ glob: (
57
+ pattern: string,
58
+ opts: { eager: true; import: string; query?: string },
59
+ ) => Record<string, unknown>
60
+ }).glob('/.dcs/forms/*.yaml', { eager: true, import: 'default' })
61
+
62
+ const definitions = computed(() =>
63
+ loadFormDefinitions(props.formsModules ?? eagerFormsModules),
64
+ )
65
+
66
+ const definition = computed<PortalFormDefinition | null>(() => {
67
+ if (props.definitionOverride) return props.definitionOverride
68
+ return definitions.value[props.formId] ?? null
69
+ })
70
+
71
+ const isDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV
72
+
73
+ watch(
74
+ definition,
75
+ (def) => {
76
+ if (def) warnIfInvalid(props.formId, def, isDev)
77
+ },
78
+ { immediate: true },
79
+ )
80
+
81
+ // We always call useDcsForm with *some* definition so hooks render
82
+ // stably; if the form is missing we'll show an error state instead.
83
+ const safeDefinition = computed<PortalFormDefinition>(
84
+ () =>
85
+ definition.value ?? {
86
+ formId: props.formId,
87
+ title: '',
88
+ submission: { kind: 'lead' },
89
+ fields: [],
90
+ },
91
+ )
92
+
93
+ const form = useDcsForm({ definition: safeDefinition.value })
94
+
95
+ // If the resolved definition changes (e.g. preview iframe updates),
96
+ // re-create derived state by resetting.
97
+ watch(
98
+ () => safeDefinition.value,
99
+ () => form.reset(),
100
+ )
101
+
102
+ const apiBase = computed(
103
+ () =>
104
+ props.apiBase ??
105
+ (import.meta as unknown as { env?: { VITE_DCS_PUBLIC_API?: string } }).env
106
+ ?.VITE_DCS_PUBLIC_API ??
107
+ '',
108
+ )
109
+
110
+ const resolvedSiteSlug = computed(
111
+ () =>
112
+ props.siteSlug ??
113
+ (import.meta as unknown as { env?: { VITE_DCS_SITE_SLUG?: string } }).env
114
+ ?.VITE_DCS_SITE_SLUG ??
115
+ '',
116
+ )
117
+
118
+ const submitLabel = computed(
119
+ () => safeDefinition.value.submitLabel ?? 'Send',
120
+ )
121
+
122
+ function fieldComponent(field: PortalFormField) {
123
+ switch (field.type) {
124
+ case 'text':
125
+ case 'email':
126
+ case 'tel':
127
+ return DcsFormText
128
+ case 'textarea':
129
+ return DcsFormTextarea
130
+ case 'select':
131
+ case 'multiselect':
132
+ return DcsFormSelect
133
+ case 'radio':
134
+ return DcsFormRadio
135
+ case 'checkbox-group':
136
+ return DcsFormCheckboxGroup
137
+ case 'checkbox':
138
+ return DcsFormCheckbox
139
+ case 'date':
140
+ return DcsFormDate
141
+ case 'file':
142
+ return DcsFormFile
143
+ case 'hidden':
144
+ return DcsFormHidden
145
+ case 'section-heading':
146
+ return DcsFormSection
147
+ case 'html-block':
148
+ return DcsFormHtmlBlock
149
+ default:
150
+ return DcsFormText
151
+ }
152
+ }
153
+
154
+ const formEl = ref<HTMLFormElement | null>(null)
155
+
156
+ async function onSubmit(e: Event): Promise<void> {
157
+ e.preventDefault()
158
+ if (!definition.value) return
159
+ const ok = form.validateAll()
160
+ if (!ok) {
161
+ emit('validation-error', form.errors.value)
162
+ return
163
+ }
164
+ form.submitting.value = true
165
+ form.submitError.value = null
166
+ const payload = {
167
+ formId: props.formId,
168
+ values: form.collectSubmissionValues(),
169
+ captchaToken: props.captchaToken,
170
+ }
171
+ try {
172
+ const result = await submitFormValues({
173
+ apiBase: apiBase.value,
174
+ siteSlug: resolvedSiteSlug.value,
175
+ payload,
176
+ })
177
+ form.submitted.value = true
178
+ emit('submit-success', result)
179
+ } catch (err) {
180
+ const e2 = err as DcsFormSubmitError
181
+ form.submitError.value = e2.error?.message ?? 'Submission failed'
182
+ emit('submit-error', e2)
183
+ } finally {
184
+ form.submitting.value = false
185
+ }
186
+ }
187
+
188
+ onMounted(() => {
189
+ if (!definition.value) {
190
+ // eslint-disable-next-line no-console
191
+ console.warn(
192
+ `[@duffcloudservices/site-forms] No form definition found for "${props.formId}". ` +
193
+ `Expected a YAML at .dcs/forms/${props.formId}.yaml. ` +
194
+ `In customer-site repos the YAML lives above the Vite root, so the ` +
195
+ `internal auto-glob will not find it — pass formsModules explicitly: ` +
196
+ `<DcsForm form-id="${props.formId}" :forms-modules="dcsFormsModules" />. ` +
197
+ `See @duffcloudservices/site-forms README "Vite setup" section.`,
198
+ )
199
+ }
200
+ })
201
+ </script>
202
+
203
+ <template>
204
+ <div v-if="!definition" class="dcs-form dcs-form--missing" :data-form-key="formId">
205
+ <slot name="missing" :form-id="formId">
206
+ <p>Form &ldquo;{{ formId }}&rdquo; is not configured.</p>
207
+ </slot>
208
+ </div>
209
+
210
+ <div v-else-if="form.submitted.value" class="dcs-form dcs-form--success" :data-form-key="formId">
211
+ <slot name="success" :definition="definition">
212
+ <p>{{ definition.successMessage ?? 'Thanks — we received your message.' }}</p>
213
+ </slot>
214
+ </div>
215
+
216
+ <form
217
+ v-else
218
+ ref="formEl"
219
+ class="dcs-form"
220
+ :data-form-key="formId"
221
+ novalidate
222
+ @submit="onSubmit"
223
+ >
224
+ <slot name="header" :definition="definition">
225
+ <header class="dcs-form__header">
226
+ <h2 class="dcs-form__title">{{ definition.title }}</h2>
227
+ <p v-if="definition.description" class="dcs-form__description">
228
+ {{ definition.description }}
229
+ </p>
230
+ </header>
231
+ </slot>
232
+
233
+ <div v-if="form.steps.value" class="dcs-form__progress" aria-live="polite">
234
+ <slot
235
+ name="progress"
236
+ :current="form.currentStepIndex.value"
237
+ :total="form.steps.value.length"
238
+ :step="form.currentStep.value"
239
+ >
240
+ <p>
241
+ Step {{ form.currentStepIndex.value + 1 }} of
242
+ {{ form.steps.value.length }}
243
+ <template v-if="form.currentStep.value">
244
+ — {{ form.currentStep.value.title }}
245
+ </template>
246
+ </p>
247
+ </slot>
248
+ </div>
249
+
250
+ <div class="dcs-form__fields">
251
+ <component
252
+ :is="fieldComponent(field)"
253
+ v-for="field in form.visibleFields.value"
254
+ :key="field.id"
255
+ :field="field"
256
+ :model-value="form.values[field.id]"
257
+ :error="form.errors.value[field.id]"
258
+ @update:model-value="(v: unknown) => form.setValue(field.id, v)"
259
+ @blur="form.touch(field.id)"
260
+ />
261
+ </div>
262
+
263
+ <div v-if="form.submitError.value" class="dcs-form__submit-error" role="alert">
264
+ {{ form.submitError.value }}
265
+ </div>
266
+
267
+ <div class="dcs-form__actions">
268
+ <slot
269
+ name="actions"
270
+ :is-first-step="form.isFirstStep.value"
271
+ :is-last-step="form.isLastStep.value"
272
+ :submitting="form.submitting.value"
273
+ :prev="form.prev"
274
+ :next="form.next"
275
+ >
276
+ <button
277
+ v-if="form.steps.value && !form.isFirstStep.value"
278
+ type="button"
279
+ class="dcs-form__btn dcs-form__btn--prev"
280
+ @click="form.prev()"
281
+ >
282
+ Previous
283
+ </button>
284
+ <button
285
+ v-if="form.steps.value && !form.isLastStep.value"
286
+ type="button"
287
+ class="dcs-form__btn dcs-form__btn--next"
288
+ @click="form.next()"
289
+ >
290
+ Next
291
+ </button>
292
+ <button
293
+ v-if="!form.steps.value || form.isLastStep.value"
294
+ type="submit"
295
+ class="dcs-form__btn dcs-form__btn--submit"
296
+ :disabled="form.submitting.value"
297
+ >
298
+ {{ form.submitting.value ? 'Sending…' : submitLabel }}
299
+ </button>
300
+ </slot>
301
+ </div>
302
+ </form>
303
+ </template>