@duffcloudservices/site-forms 0.4.2 → 0.5.0
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/dist/composables/useFormSubmission.d.ts +5 -1
- package/dist/fields/DcsFormDate.vue.d.ts +2 -2
- package/dist/fields/DcsFormSelect.vue.d.ts +2 -2
- package/dist/fields/DcsFormText.vue.d.ts +2 -2
- package/dist/fields/DcsFormTextarea.vue.d.ts +2 -2
- package/dist/index.js +899 -536
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/DcsForm.vue +37 -4
- package/src/__tests__/missing-form-error-surfacing.test.ts +119 -0
- package/src/__tests__/submission.test.ts +24 -0
- package/src/composables/useFormSubmission.ts +11 -4
package/src/DcsForm.vue
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
PortalFormDefinition,
|
|
7
7
|
PortalFormField,
|
|
8
8
|
} from './types'
|
|
9
|
+
import { useUiSound } from '@dcs/ui/sound'
|
|
9
10
|
import { useDcsForm } from './composables/useDcsForm'
|
|
10
11
|
import { submitFormValues } from './composables/useFormSubmission'
|
|
11
12
|
import { warnIfInvalid } from './schema/validate'
|
|
@@ -91,6 +92,17 @@ const safeDefinition = computed<PortalFormDefinition>(
|
|
|
91
92
|
|
|
92
93
|
const form = useDcsForm({ definition: safeDefinition.value })
|
|
93
94
|
|
|
95
|
+
// First-party form-success chime (dcs-product-sound). Synthesis lives entirely in
|
|
96
|
+
// the shared engine — this component owns no AudioContext. Calling useUiSound() here
|
|
97
|
+
// also binds the engine's gesture-unlock listeners, so the user's submit click
|
|
98
|
+
// (pointerdown/keydown) resumes the AudioContext synchronously in the gesture;
|
|
99
|
+
// the chime that fires after the async submit resolves then plays on a running
|
|
100
|
+
// context (autoplay-safe by construction). It stays SILENT by default: customer
|
|
101
|
+
// sites are silent-by-default (they never seed sound via configureSoundDefaults),
|
|
102
|
+
// and the engine additionally hard-silences inside the portal editor iframe. The
|
|
103
|
+
// chime activates only once a site opts into sound.
|
|
104
|
+
const { play: playUiSound } = useUiSound()
|
|
105
|
+
|
|
94
106
|
// If the resolved definition changes (e.g. preview iframe updates),
|
|
95
107
|
// re-create derived state by resetting.
|
|
96
108
|
watch(
|
|
@@ -98,13 +110,31 @@ watch(
|
|
|
98
110
|
() => form.reset(),
|
|
99
111
|
)
|
|
100
112
|
|
|
101
|
-
const apiBase = computed(
|
|
102
|
-
|
|
113
|
+
const apiBase = computed(() => {
|
|
114
|
+
const configured =
|
|
103
115
|
props.apiBase ??
|
|
104
116
|
(import.meta as unknown as { env?: { VITE_DCS_PUBLIC_API?: string } }).env
|
|
105
117
|
?.VITE_DCS_PUBLIC_API ??
|
|
106
|
-
''
|
|
107
|
-
)
|
|
118
|
+
''
|
|
119
|
+
if (configured) return configured
|
|
120
|
+
return defaultPublicApiBase()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fallback API base when neither the `api-base` prop nor VITE_DCS_PUBLIC_API
|
|
125
|
+
* is set. A customer apex does not route `/api/v1/sites/*` (an empty base
|
|
126
|
+
* produced a same-origin POST the static host rejects with a 405), so default
|
|
127
|
+
* to the shared public API host; localhost keeps the relative base for the
|
|
128
|
+
* dev proxy.
|
|
129
|
+
*/
|
|
130
|
+
function defaultPublicApiBase(): string {
|
|
131
|
+
if (typeof window === 'undefined') return ''
|
|
132
|
+
const host = window.location.hostname.toLowerCase()
|
|
133
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
|
|
134
|
+
return '/api/v1'
|
|
135
|
+
}
|
|
136
|
+
return 'https://api.duffcloudservices.com/api/v1'
|
|
137
|
+
}
|
|
108
138
|
|
|
109
139
|
const resolvedSiteSlug = computed(
|
|
110
140
|
() =>
|
|
@@ -182,6 +212,9 @@ async function onSubmit(e: Event): Promise<void> {
|
|
|
182
212
|
payload,
|
|
183
213
|
})
|
|
184
214
|
form.submitted.value = true
|
|
215
|
+
// Success confirmed by the server (never on optimistic fire): play only after
|
|
216
|
+
// the submission resolves. Inert unless the site has enabled sound.
|
|
217
|
+
playUiSound('notification.success')
|
|
185
218
|
emit('submit-success', result)
|
|
186
219
|
} catch (err) {
|
|
187
220
|
const e2 = err as DcsFormSubmitError
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
|
+
import { mount, flushPromises } from '@vue/test-utils'
|
|
3
|
+
import DcsForm from '../DcsForm.vue'
|
|
4
|
+
import type { PortalFormDefinition } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* C-155 verification — the Lamphere W6 "silent submit failure".
|
|
8
|
+
*
|
|
9
|
+
* Original finding (.docs/archive/plans/lamphere-reunion-redesign/plan.md,
|
|
10
|
+
* Learnings): prod `Features` lacked `contactForm` and the `PortalSiteForms`
|
|
11
|
+
* row was never applied — "the form silently accepted-then-reset with no
|
|
12
|
+
* user-visible error".
|
|
13
|
+
*
|
|
14
|
+
* Today both of those misconfigurations make the server's public submission
|
|
15
|
+
* handler return 404 "Form not found" (server/internal/handlers/
|
|
16
|
+
* public_site_forms.go — ErrSiteNotFound / ErrFormNotFound /
|
|
17
|
+
* ErrFormFeatureDisabled all map to http.StatusNotFound). This suite proves
|
|
18
|
+
* the client surfaces that 404 loudly: the submit error renders in
|
|
19
|
+
* `.dcs-form__submit-error` with role="alert", the form does NOT flip to the
|
|
20
|
+
* success state, and the user's field values are NOT reset.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const def: PortalFormDefinition = {
|
|
24
|
+
formId: 'contact',
|
|
25
|
+
submission: { kind: 'lead' },
|
|
26
|
+
fields: [
|
|
27
|
+
{ id: 'name', type: 'text', label: 'Name', required: true },
|
|
28
|
+
{ id: 'email', type: 'email', label: 'Email', required: true },
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const originalFetch = globalThis.fetch
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('missing/misconfigured server form (Lamphere W6 repro)', () => {
|
|
39
|
+
it('surfaces a visible role=alert error on 404 and does not reset or fake success', async () => {
|
|
40
|
+
// The Lamphere shape: the site repo HAS a valid local YAML definition
|
|
41
|
+
// (the form renders and validates fine), but the server side was never
|
|
42
|
+
// provisioned — POST returns 404 "Form not found".
|
|
43
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
44
|
+
ok: false,
|
|
45
|
+
status: 404,
|
|
46
|
+
text: async () => '{"error":"Form not found"}',
|
|
47
|
+
})
|
|
48
|
+
;(globalThis as unknown as { fetch: typeof fetch }).fetch =
|
|
49
|
+
fetchMock as unknown as typeof fetch
|
|
50
|
+
|
|
51
|
+
const wrapper = mount(DcsForm, {
|
|
52
|
+
props: {
|
|
53
|
+
formId: 'contact',
|
|
54
|
+
siteSlug: 'lamphere-class-2005',
|
|
55
|
+
definitionOverride: def,
|
|
56
|
+
apiBase: 'https://api.example.com',
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
|
|
61
|
+
await wrapper
|
|
62
|
+
.find('[data-form-field-key="email"] input')
|
|
63
|
+
.setValue('jane@example.com')
|
|
64
|
+
|
|
65
|
+
await wrapper.find('form').trigger('submit')
|
|
66
|
+
await flushPromises()
|
|
67
|
+
|
|
68
|
+
// 4xx must not be retried — one POST only.
|
|
69
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
70
|
+
|
|
71
|
+
// The failure is loud: a rendered, role="alert" error block.
|
|
72
|
+
const alert = wrapper.find('.dcs-form__submit-error')
|
|
73
|
+
expect(alert.exists()).toBe(true)
|
|
74
|
+
expect(alert.attributes('role')).toBe('alert')
|
|
75
|
+
expect(alert.text()).toContain('Submission failed (404)')
|
|
76
|
+
|
|
77
|
+
// And machine-visible: submit-error emitted with the 404, no submit-success.
|
|
78
|
+
const errEvents = wrapper.emitted('submit-error')
|
|
79
|
+
expect(errEvents?.length).toBe(1)
|
|
80
|
+
expect((errEvents![0][0] as { status?: number }).status).toBe(404)
|
|
81
|
+
expect(wrapper.emitted('submit-success')).toBeUndefined()
|
|
82
|
+
|
|
83
|
+
// No fake success state ("accepted") ...
|
|
84
|
+
expect(wrapper.find('.dcs-form--success').exists()).toBe(false)
|
|
85
|
+
|
|
86
|
+
// ... and no reset: the user's values survive the failed submit.
|
|
87
|
+
expect(
|
|
88
|
+
(wrapper.find('[data-form-field-key="name"] input').element as HTMLInputElement)
|
|
89
|
+
.value,
|
|
90
|
+
).toBe('Jane')
|
|
91
|
+
expect(
|
|
92
|
+
(wrapper.find('[data-form-field-key="email"] input').element as HTMLInputElement)
|
|
93
|
+
.value,
|
|
94
|
+
).toBe('jane@example.com')
|
|
95
|
+
|
|
96
|
+
// The submit button is re-enabled for a retry, still on the form view.
|
|
97
|
+
expect(wrapper.find('form').exists()).toBe(true)
|
|
98
|
+
expect(
|
|
99
|
+
(wrapper.find('.dcs-form__btn--submit').element as HTMLButtonElement).disabled,
|
|
100
|
+
).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('renders a visible not-configured state when the local definition is missing', async () => {
|
|
104
|
+
// The other misconfiguration direction: no local YAML at all. The
|
|
105
|
+
// component renders an explicit missing state instead of an empty form.
|
|
106
|
+
const wrapper = mount(DcsForm, {
|
|
107
|
+
props: {
|
|
108
|
+
formId: 'ghost-form',
|
|
109
|
+
siteSlug: 'lamphere-class-2005',
|
|
110
|
+
formsModules: {},
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const missing = wrapper.find('.dcs-form--missing')
|
|
115
|
+
expect(missing.exists()).toBe(true)
|
|
116
|
+
expect(missing.text()).toContain('is not configured')
|
|
117
|
+
expect(wrapper.find('form').exists()).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -75,6 +75,30 @@ describe('submission happy path', () => {
|
|
|
75
75
|
expect(wrapper.emitted('validation-error')?.length).toBe(1)
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
+
it('posts to the slug-free origin-inferred route when no site slug is configured', async () => {
|
|
79
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
80
|
+
ok: true,
|
|
81
|
+
status: 202,
|
|
82
|
+
json: async () => ({ status: 'ok' }),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
await submitFormValues({
|
|
86
|
+
apiBase: 'https://api.example.com/api/v1/',
|
|
87
|
+
siteSlug: '',
|
|
88
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
89
|
+
payload: {
|
|
90
|
+
formId: 'contact',
|
|
91
|
+
values: { name: 'Jane' },
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
96
|
+
const [url] = fetchMock.mock.calls[0]
|
|
97
|
+
// An empty slug must NEVER emit the broken /sites//forms/... shape
|
|
98
|
+
// (kimduffhomes.com 405, 2026-07-20).
|
|
99
|
+
expect(url).toBe('https://api.example.com/api/v1/forms/contact/submissions')
|
|
100
|
+
})
|
|
101
|
+
|
|
78
102
|
it('uses multipart form data for file submissions on the managed-form route', async () => {
|
|
79
103
|
const fetchMock = vi.fn().mockResolvedValue({
|
|
80
104
|
ok: true,
|
|
@@ -16,7 +16,11 @@ export interface SubmitOptions {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* POSTs a form submission to
|
|
19
|
-
* `${apiBase}/sites/{siteSlug}/forms/{formId}/submissions
|
|
19
|
+
* `${apiBase}/sites/{siteSlug}/forms/{formId}/submissions`, or — when no site
|
|
20
|
+
* slug is configured — to the slug-free
|
|
21
|
+
* `${apiBase}/forms/{formId}/submissions`, where the server infers the site
|
|
22
|
+
* from the request origin/host. A missing slug used to produce the broken
|
|
23
|
+
* `/sites//forms/...` shape (kimduffhomes.com 405, 2026-07-20).
|
|
20
24
|
*
|
|
21
25
|
* Uses JSON for plain values and `multipart/form-data` when any
|
|
22
26
|
* value is a `File` (file-upload fields).
|
|
@@ -27,9 +31,12 @@ export async function submitFormValues(
|
|
|
27
31
|
const { apiBase, siteSlug, payload } = opts
|
|
28
32
|
const retries = opts.retries ?? 1
|
|
29
33
|
const fetchImpl = opts.fetchImpl ?? fetch
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
34
|
+
const base = apiBase.replace(/\/$/, '')
|
|
35
|
+
const formPath = `forms/${encodeURIComponent(payload.formId)}/submissions`
|
|
36
|
+
const trimmedSlug = siteSlug.trim()
|
|
37
|
+
const url = trimmedSlug
|
|
38
|
+
? `${base}/sites/${encodeURIComponent(trimmedSlug)}/${formPath}`
|
|
39
|
+
: `${base}/${formPath}`
|
|
33
40
|
|
|
34
41
|
const hasFile = Object.values(payload.values).some(valueHasFile)
|
|
35
42
|
|