@duffcloudservices/site-forms 0.4.1 → 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/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 url = `${apiBase.replace(/\/$/, '')}/sites/${encodeURIComponent(
31
- siteSlug,
32
- )}/forms/${encodeURIComponent(payload.formId)}/submissions`
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