@duffcloudservices/site-forms 0.1.1 → 0.1.3

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 (41) hide show
  1. package/README.md +274 -260
  2. package/dist/composables/useFormSubmission.d.ts +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +554 -398
  5. package/dist/index.js.map +1 -1
  6. package/dist/presets.d.ts +13 -0
  7. package/dist/site-forms.css +1 -0
  8. package/dist/types.d.ts +12 -2
  9. package/package.json +72 -73
  10. package/src/DcsForm.vue +295 -303
  11. package/src/__tests__/fields.test.ts +81 -82
  12. package/src/__tests__/multi-step.test.ts +45 -46
  13. package/src/__tests__/presets.test.ts +64 -0
  14. package/src/__tests__/schema.test.ts +41 -42
  15. package/src/__tests__/style-import.test.ts +9 -0
  16. package/src/__tests__/submission.test.ts +115 -77
  17. package/src/__tests__/validation.test.ts +29 -0
  18. package/src/__tests__/visible-if.test.ts +110 -111
  19. package/src/composables/useDcsForm.ts +201 -201
  20. package/src/composables/useFormSubmission.ts +113 -113
  21. package/src/composables/useFormValidation.ts +128 -127
  22. package/src/fields/DcsFormCheckbox.vue +35 -35
  23. package/src/fields/DcsFormCheckboxGroup.vue +52 -52
  24. package/src/fields/DcsFormDate.vue +34 -34
  25. package/src/fields/DcsFormFieldWrapper.vue +39 -39
  26. package/src/fields/DcsFormFile.vue +38 -38
  27. package/src/fields/DcsFormHidden.vue +17 -17
  28. package/src/fields/DcsFormHtmlBlock.vue +19 -19
  29. package/src/fields/DcsFormRadio.vue +45 -45
  30. package/src/fields/DcsFormSection.vue +19 -19
  31. package/src/fields/DcsFormSelect.vue +62 -62
  32. package/src/fields/DcsFormText.vue +54 -54
  33. package/src/fields/DcsFormTextarea.vue +43 -43
  34. package/src/index.ts +64 -51
  35. package/src/loaders/yaml.ts +51 -51
  36. package/src/presets.ts +192 -0
  37. package/src/schema/form-definition.schema.json +410 -45
  38. package/src/schema/validate.ts +58 -58
  39. package/src/shims.d.ts +10 -10
  40. package/src/style.css +256 -0
  41. package/src/types.ts +164 -140
@@ -1,82 +1,81 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { mount } from '@vue/test-utils'
3
- import DcsForm from '../DcsForm.vue'
4
- import type { PortalFormDefinition } from '../types'
5
-
6
- function makeDef(
7
- overrides: Partial<PortalFormDefinition> = {},
8
- ): PortalFormDefinition {
9
- return {
10
- formId: 'contact',
11
- title: 'Contact',
12
- submission: { kind: 'lead' },
13
- fields: [
14
- { id: 'name', type: 'text', label: 'Name', required: true },
15
- { id: 'email', type: 'email', label: 'Email', required: true },
16
- { id: 'message', type: 'textarea', label: 'Message' },
17
- ],
18
- ...overrides,
19
- }
20
- }
21
-
22
- describe('field rendering', () => {
23
- it('emits data-form-key on the form element', () => {
24
- const wrapper = mount(DcsForm, {
25
- props: { formId: 'contact', siteSlug: 's', definitionOverride: makeDef() },
26
- })
27
- const form = wrapper.find('form')
28
- expect(form.attributes('data-form-key')).toBe('contact')
29
- })
30
-
31
- it('renders an input per text field with data-form-field-key', () => {
32
- const wrapper = mount(DcsForm, {
33
- props: { formId: 'contact', siteSlug: 's', definitionOverride: makeDef() },
34
- })
35
- expect(wrapper.find('[data-form-field-key="name"] input[type="text"]').exists()).toBe(true)
36
- expect(wrapper.find('[data-form-field-key="email"] input[type="email"]').exists()).toBe(true)
37
- expect(wrapper.find('[data-form-field-key="message"] textarea').exists()).toBe(true)
38
- })
39
-
40
- it('renders select / radio / checkbox-group / date / file / hidden / section / html-block', () => {
41
- const def = makeDef({
42
- fields: [
43
- {
44
- id: 'sel',
45
- type: 'select',
46
- label: 'Sel',
47
- options: [{ value: 'a', label: 'A' }],
48
- },
49
- {
50
- id: 'rad',
51
- type: 'radio',
52
- label: 'Rad',
53
- options: [{ value: 'a', label: 'A' }],
54
- },
55
- {
56
- id: 'chg',
57
- type: 'checkbox-group',
58
- label: 'Chg',
59
- options: [{ value: 'a', label: 'A' }],
60
- },
61
- { id: 'cb', type: 'checkbox', label: 'CB' },
62
- { id: 'dt', type: 'date', label: 'Dt' },
63
- { id: 'f', type: 'file', label: 'F' },
64
- { id: 'h', type: 'hidden', label: 'H', defaultValue: 'x' },
65
- { id: 'sec', type: 'section-heading', label: 'Sec' },
66
- { id: 'html', type: 'html-block', label: 'HTML', html: '<p>hi</p>' },
67
- ],
68
- })
69
- const wrapper = mount(DcsForm, {
70
- props: { formId: 'contact', siteSlug: 's', definitionOverride: def },
71
- })
72
- expect(wrapper.find('[data-form-field-key="sel"] select').exists()).toBe(true)
73
- expect(wrapper.find('[data-form-field-key="rad"] input[type="radio"]').exists()).toBe(true)
74
- expect(wrapper.find('[data-form-field-key="chg"] input[type="checkbox"]').exists()).toBe(true)
75
- expect(wrapper.find('[data-form-field-key="cb"] input[type="checkbox"]').exists()).toBe(true)
76
- expect(wrapper.find('[data-form-field-key="dt"] input[type="date"]').exists()).toBe(true)
77
- expect(wrapper.find('[data-form-field-key="f"] input[type="file"]').exists()).toBe(true)
78
- expect(wrapper.find('input[type="hidden"][name="h"]').exists()).toBe(true)
79
- expect(wrapper.find('[data-form-field-key="sec"] h3').exists()).toBe(true)
80
- expect(wrapper.find('[data-form-field-key="html"]').html()).toContain('<p>hi</p>')
81
- })
82
- })
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import type { PortalFormDefinition } from '../types'
5
+
6
+ function makeDef(
7
+ overrides: Partial<PortalFormDefinition> = {},
8
+ ): PortalFormDefinition {
9
+ return {
10
+ formId: 'contact',
11
+ submission: { kind: 'lead' },
12
+ fields: [
13
+ { id: 'name', type: 'text', label: 'Name', required: true },
14
+ { id: 'email', type: 'email', label: 'Email', required: true },
15
+ { id: 'message', type: 'textarea', label: 'Message' },
16
+ ],
17
+ ...overrides,
18
+ }
19
+ }
20
+
21
+ describe('field rendering', () => {
22
+ it('emits data-form-key on the form element', () => {
23
+ const wrapper = mount(DcsForm, {
24
+ props: { formId: 'contact', siteSlug: 's', definitionOverride: makeDef() },
25
+ })
26
+ const form = wrapper.find('form')
27
+ expect(form.attributes('data-form-key')).toBe('contact')
28
+ })
29
+
30
+ it('renders an input per text field with data-form-field-key', () => {
31
+ const wrapper = mount(DcsForm, {
32
+ props: { formId: 'contact', siteSlug: 's', definitionOverride: makeDef() },
33
+ })
34
+ expect(wrapper.find('[data-form-field-key="name"] input[type="text"]').exists()).toBe(true)
35
+ expect(wrapper.find('[data-form-field-key="email"] input[type="email"]').exists()).toBe(true)
36
+ expect(wrapper.find('[data-form-field-key="message"] textarea').exists()).toBe(true)
37
+ })
38
+
39
+ it('renders select / radio / checkbox-group / date / file / hidden / section / html-block', () => {
40
+ const def = makeDef({
41
+ fields: [
42
+ {
43
+ id: 'sel',
44
+ type: 'select',
45
+ label: 'Sel',
46
+ options: [{ value: 'a', label: 'A' }],
47
+ },
48
+ {
49
+ id: 'rad',
50
+ type: 'radio',
51
+ label: 'Rad',
52
+ options: [{ value: 'a', label: 'A' }],
53
+ },
54
+ {
55
+ id: 'chg',
56
+ type: 'checkbox-group',
57
+ label: 'Chg',
58
+ options: [{ value: 'a', label: 'A' }],
59
+ },
60
+ { id: 'cb', type: 'checkbox', label: 'CB' },
61
+ { id: 'dt', type: 'date', label: 'Dt' },
62
+ { id: 'f', type: 'file', label: 'F' },
63
+ { id: 'h', type: 'hidden', label: 'H', defaultValue: 'x' },
64
+ { id: 'sec', type: 'section-heading', label: 'Sec' },
65
+ { id: 'html', type: 'html-block', label: 'HTML', html: '<p>hi</p>' },
66
+ ],
67
+ })
68
+ const wrapper = mount(DcsForm, {
69
+ props: { formId: 'contact', siteSlug: 's', definitionOverride: def },
70
+ })
71
+ expect(wrapper.find('[data-form-field-key="sel"] select').exists()).toBe(true)
72
+ expect(wrapper.find('[data-form-field-key="rad"] input[type="radio"]').exists()).toBe(true)
73
+ expect(wrapper.find('[data-form-field-key="chg"] input[type="checkbox"]').exists()).toBe(true)
74
+ expect(wrapper.find('[data-form-field-key="cb"] input[type="checkbox"]').exists()).toBe(true)
75
+ expect(wrapper.find('[data-form-field-key="dt"] input[type="date"]').exists()).toBe(true)
76
+ expect(wrapper.find('[data-form-field-key="f"] input[type="file"]').exists()).toBe(true)
77
+ expect(wrapper.find('input[type="hidden"][name="h"]').exists()).toBe(true)
78
+ expect(wrapper.find('[data-form-field-key="sec"] h3').exists()).toBe(true)
79
+ expect(wrapper.find('[data-form-field-key="html"]').html()).toContain('<p>hi</p>')
80
+ })
81
+ })
@@ -1,46 +1,45 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { mount, flushPromises } from '@vue/test-utils'
3
- import DcsForm from '../DcsForm.vue'
4
- import type { PortalFormDefinition } from '../types'
5
-
6
- const def: PortalFormDefinition = {
7
- formId: 'wiz',
8
- title: 'Wizard',
9
- submission: { kind: 'lead' },
10
- steps: [
11
- { id: 's1', title: 'One', fieldIds: ['a'] },
12
- { id: 's2', title: 'Two', fieldIds: ['b'] },
13
- ],
14
- fields: [
15
- { id: 'a', type: 'text', label: 'A', required: true },
16
- { id: 'b', type: 'text', label: 'B', required: true },
17
- ],
18
- }
19
-
20
- describe('multi-step navigation', () => {
21
- it('starts on step 1, blocks Next until current step is valid', async () => {
22
- const wrapper = mount(DcsForm, {
23
- props: { formId: 'wiz', siteSlug: 's', definitionOverride: def },
24
- })
25
- expect(wrapper.text()).toContain('Step 1 of 2')
26
- // Step 1 field B is not visible yet
27
- expect(wrapper.find('[data-form-field-key="a"]').exists()).toBe(true)
28
- expect(wrapper.find('[data-form-field-key="b"]').exists()).toBe(false)
29
-
30
- // Click Next without filling A — should not advance
31
- await wrapper.find('.dcs-form__btn--next').trigger('click')
32
- expect(wrapper.text()).toContain('Step 1 of 2')
33
- expect(wrapper.text()).toContain('A is required')
34
-
35
- // Fill A, then advance
36
- await wrapper.find('[data-form-field-key="a"] input').setValue('hi')
37
- await wrapper.find('.dcs-form__btn--next').trigger('click')
38
- await flushPromises()
39
- expect(wrapper.text()).toContain('Step 2 of 2')
40
- expect(wrapper.find('[data-form-field-key="b"]').exists()).toBe(true)
41
-
42
- // Prev returns
43
- await wrapper.find('.dcs-form__btn--prev').trigger('click')
44
- expect(wrapper.text()).toContain('Step 1 of 2')
45
- })
46
- })
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import type { PortalFormDefinition } from '../types'
5
+
6
+ const def: PortalFormDefinition = {
7
+ formId: 'wiz',
8
+ submission: { kind: 'lead' },
9
+ steps: [
10
+ { id: 's1', title: 'One', fieldIds: ['a'] },
11
+ { id: 's2', title: 'Two', fieldIds: ['b'] },
12
+ ],
13
+ fields: [
14
+ { id: 'a', type: 'text', label: 'A', required: true },
15
+ { id: 'b', type: 'text', label: 'B', required: true },
16
+ ],
17
+ }
18
+
19
+ describe('multi-step navigation', () => {
20
+ it('starts on step 1, blocks Next until current step is valid', async () => {
21
+ const wrapper = mount(DcsForm, {
22
+ props: { formId: 'wiz', siteSlug: 's', definitionOverride: def },
23
+ })
24
+ expect(wrapper.text()).toContain('Step 1 of 2')
25
+ // Step 1 field B is not visible yet
26
+ expect(wrapper.find('[data-form-field-key="a"]').exists()).toBe(true)
27
+ expect(wrapper.find('[data-form-field-key="b"]').exists()).toBe(false)
28
+
29
+ // Click Next without filling A — should not advance
30
+ await wrapper.find('.dcs-form__btn--next').trigger('click')
31
+ expect(wrapper.text()).toContain('Step 1 of 2')
32
+ expect(wrapper.text()).toContain('A is required')
33
+
34
+ // Fill A, then advance
35
+ await wrapper.find('[data-form-field-key="a"] input').setValue('hi')
36
+ await wrapper.find('.dcs-form__btn--next').trigger('click')
37
+ await flushPromises()
38
+ expect(wrapper.text()).toContain('Step 2 of 2')
39
+ expect(wrapper.find('[data-form-field-key="b"]').exists()).toBe(true)
40
+
41
+ // Prev returns
42
+ await wrapper.find('.dcs-form__btn--prev').trigger('click')
43
+ expect(wrapper.text()).toContain('Step 1 of 2')
44
+ })
45
+ })
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { buildStandardFormDefinition } from '../presets'
4
+
5
+ describe('buildStandardFormDefinition', () => {
6
+ it('builds the standard contact scaffold with contact-compatible field ids', () => {
7
+ const definition = buildStandardFormDefinition('contact')
8
+
9
+ expect(definition.formId).toBe('contact')
10
+ expect(definition.formKind).toBe('contact')
11
+ expect(definition.fields.map(field => field.id)).toEqual(['name', 'email', 'phone', 'company', 'message'])
12
+ expect(definition.fields.map(field => field.role)).toEqual([
13
+ 'contact-name',
14
+ 'contact-email',
15
+ 'contact-phone',
16
+ 'contact-company',
17
+ 'message',
18
+ ])
19
+ })
20
+
21
+ it('builds the revenue contractor scaffold with project summary and image upload', () => {
22
+ const definition = buildStandardFormDefinition('revenue-contractor')
23
+
24
+ expect(definition.formKind).toBe('revenue-contractor')
25
+ expect(definition.attachmentPolicy).toEqual(expect.objectContaining({
26
+ expected: true,
27
+ accept: ['image/*'],
28
+ }))
29
+ expect(definition.fields.map(field => field.id)).toContain('project-image')
30
+ expect(definition.fields.find(field => field.id === 'message')?.label).toBe('Project summary')
31
+ expect(definition.fields.find(field => field.id === 'message')?.role).toBe('summary')
32
+ expect(definition.fields.find(field => field.id === 'project-image')?.role).toBe('image-attachments')
33
+ })
34
+
35
+ it('builds the resume submission scaffold with a required resume field', () => {
36
+ const definition = buildStandardFormDefinition('resume-submission')
37
+ const resumeField = definition.fields.find(field => field.id === 'resume')
38
+
39
+ expect(definition.formId).toBe('resume')
40
+ expect(definition.formKind).toBe('resume-submission')
41
+ expect(definition.attachmentPolicy).toEqual(expect.objectContaining({
42
+ expected: true,
43
+ required: true,
44
+ maxFiles: 1,
45
+ }))
46
+ expect(resumeField?.type).toBe('file')
47
+ expect(resumeField?.required).toBe(true)
48
+ expect(resumeField?.role).toBe('resume')
49
+ })
50
+
51
+ it('allows callers to override submission metadata', () => {
52
+ const definition = buildStandardFormDefinition('contact', {
53
+ formId: 'general-question',
54
+ submitLabel: 'Send question',
55
+ successMessage: 'Thanks!',
56
+ leadCategory: 'contractor',
57
+ })
58
+
59
+ expect(definition.formId).toBe('general-question')
60
+ expect(definition.submitLabel).toBe('Send question')
61
+ expect(definition.successMessage).toBe('Thanks!')
62
+ expect(definition.submission).toEqual({ kind: 'lead', category: 'contractor' })
63
+ })
64
+ })
@@ -1,42 +1,41 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { validateFormDefinition, warnIfInvalid } from '../schema/validate'
3
- import type { PortalFormDefinition } from '../types'
4
-
5
- const valid: PortalFormDefinition = {
6
- formId: 'contact',
7
- title: 'Contact',
8
- submission: { kind: 'lead' },
9
- fields: [
10
- { id: 'name', type: 'text', label: 'Name', required: true },
11
- { id: 'email', type: 'email', label: 'Email', required: true },
12
- ],
13
- }
14
-
15
- describe('schema validate', () => {
16
- it('passes for a well-formed definition', () => {
17
- const r = validateFormDefinition(valid)
18
- expect(r.valid).toBe(true)
19
- expect(r.errors).toEqual([])
20
- })
21
-
22
- it('fails when required keys are missing', () => {
23
- const bad = { formId: 'x' } as unknown as PortalFormDefinition
24
- const r = validateFormDefinition(bad)
25
- expect(r.valid).toBe(false)
26
- expect(r.errors.length).toBeGreaterThan(0)
27
- })
28
-
29
- it('emits a dev-only console.warn for malformed definitions', () => {
30
- const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
31
- warnIfInvalid('contact', { formId: 'contact' }, true)
32
- expect(spy).toHaveBeenCalled()
33
- spy.mockRestore()
34
- })
35
-
36
- it('does NOT warn when not in dev mode', () => {
37
- const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
38
- warnIfInvalid('contact', { formId: 'contact' }, false)
39
- expect(spy).not.toHaveBeenCalled()
40
- spy.mockRestore()
41
- })
42
- })
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { validateFormDefinition, warnIfInvalid } from '../schema/validate'
3
+ import type { PortalFormDefinition } from '../types'
4
+
5
+ const valid: PortalFormDefinition = {
6
+ formId: 'contact',
7
+ submission: { kind: 'lead' },
8
+ fields: [
9
+ { id: 'name', type: 'text', label: 'Name', required: true },
10
+ { id: 'email', type: 'email', label: 'Email', required: true },
11
+ ],
12
+ }
13
+
14
+ describe('schema validate', () => {
15
+ it('passes for a well-formed definition', () => {
16
+ const r = validateFormDefinition(valid)
17
+ expect(r.valid).toBe(true)
18
+ expect(r.errors).toEqual([])
19
+ })
20
+
21
+ it('fails when required keys are missing', () => {
22
+ const bad = { formId: 'x' } as unknown as PortalFormDefinition
23
+ const r = validateFormDefinition(bad)
24
+ expect(r.valid).toBe(false)
25
+ expect(r.errors.length).toBeGreaterThan(0)
26
+ })
27
+
28
+ it('emits a dev-only console.warn for malformed definitions', () => {
29
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
30
+ warnIfInvalid('contact', { formId: 'contact' }, true)
31
+ expect(spy).toHaveBeenCalled()
32
+ spy.mockRestore()
33
+ })
34
+
35
+ it('does NOT warn when not in dev mode', () => {
36
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
37
+ warnIfInvalid('contact', { formId: 'contact' }, false)
38
+ expect(spy).not.toHaveBeenCalled()
39
+ spy.mockRestore()
40
+ })
41
+ })
@@ -0,0 +1,9 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import * as exports from '../index'
4
+
5
+ describe('site-forms entry', () => {
6
+ it('loads the shared stylesheet from the package entry', () => {
7
+ expect(exports.DcsForm).toBeTruthy()
8
+ })
9
+ })
@@ -1,77 +1,115 @@
1
- import { describe, it, expect, vi } from 'vitest'
2
- import { mount, flushPromises } from '@vue/test-utils'
3
- import DcsForm from '../DcsForm.vue'
4
- import type { PortalFormDefinition } from '../types'
5
-
6
- const def: PortalFormDefinition = {
7
- formId: 'contact',
8
- title: 'Contact',
9
- submission: { kind: 'lead' },
10
- fields: [
11
- { id: 'name', type: 'text', label: 'Name', required: true },
12
- { id: 'email', type: 'email', label: 'Email', required: true },
13
- { id: 'message', type: 'textarea', label: 'Message' },
14
- ],
15
- }
16
-
17
- describe('submission happy path', () => {
18
- it('POSTs the right payload shape and emits submit-success', async () => {
19
- const fetchMock = vi.fn().mockResolvedValue({
20
- ok: true,
21
- status: 200,
22
- json: async () => ({ leadId: 'L-1' }),
23
- })
24
- const originalFetch = globalThis.fetch
25
- ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
26
- fetchMock as unknown as typeof fetch
27
-
28
- const wrapper = mount(DcsForm, {
29
- props: {
30
- formId: 'contact',
31
- siteSlug: 'kept',
32
- definitionOverride: def,
33
- apiBase: 'https://api.example.com/',
34
- captchaToken: 'tok-abc',
35
- },
36
- })
37
-
38
- await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
39
- await wrapper.find('[data-form-field-key="email"] input').setValue('jane@example.com')
40
- await wrapper.find('[data-form-field-key="message"] textarea').setValue('hello')
41
-
42
- await wrapper.find('form').trigger('submit')
43
- await flushPromises()
44
-
45
- expect(fetchMock).toHaveBeenCalledTimes(1)
46
- const [url, init] = fetchMock.mock.calls[0]
47
- expect(url).toBe('https://api.example.com/public/sites/kept/form-submissions')
48
- expect((init as RequestInit).method).toBe('POST')
49
- expect(((init as RequestInit).headers as Record<string, string>)['Content-Type']).toBe(
50
- 'application/json',
51
- )
52
- const body = JSON.parse((init as RequestInit).body as string)
53
- expect(body).toEqual({
54
- formId: 'contact',
55
- values: { name: 'Jane', email: 'jane@example.com', message: 'hello' },
56
- captchaToken: 'tok-abc',
57
- })
58
-
59
- expect(wrapper.emitted('submit-success')?.length).toBe(1)
60
-
61
- ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
62
- })
63
-
64
- it('emits validation-error when required fields are missing', async () => {
65
- const wrapper = mount(DcsForm, {
66
- props: {
67
- formId: 'contact',
68
- siteSlug: 'kept',
69
- definitionOverride: def,
70
- apiBase: 'https://api.example.com',
71
- },
72
- })
73
- await wrapper.find('form').trigger('submit')
74
- await flushPromises()
75
- expect(wrapper.emitted('validation-error')?.length).toBe(1)
76
- })
77
- })
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { mount, flushPromises } from '@vue/test-utils'
3
+ import DcsForm from '../DcsForm.vue'
4
+ import { submitFormValues } from '../composables/useFormSubmission'
5
+ import type { PortalFormDefinition } from '../types'
6
+
7
+ const def: PortalFormDefinition = {
8
+ formId: 'contact',
9
+ submission: { kind: 'lead' },
10
+ fields: [
11
+ { id: 'name', type: 'text', label: 'Name', required: true },
12
+ { id: 'email', type: 'email', label: 'Email', required: true },
13
+ { id: 'message', type: 'textarea', label: 'Message' },
14
+ ],
15
+ }
16
+
17
+ describe('submission happy path', () => {
18
+ it('POSTs the right payload shape and emits submit-success', async () => {
19
+ const fetchMock = vi.fn().mockResolvedValue({
20
+ ok: true,
21
+ status: 200,
22
+ json: async () => ({ leadId: 'L-1' }),
23
+ })
24
+ const originalFetch = globalThis.fetch
25
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch =
26
+ fetchMock as unknown as typeof fetch
27
+
28
+ const wrapper = mount(DcsForm, {
29
+ props: {
30
+ formId: 'contact',
31
+ siteSlug: 'kept',
32
+ definitionOverride: def,
33
+ apiBase: 'https://api.example.com/',
34
+ captchaToken: 'tok-abc',
35
+ },
36
+ })
37
+
38
+ await wrapper.find('[data-form-field-key="name"] input').setValue('Jane')
39
+ await wrapper.find('[data-form-field-key="email"] input').setValue('jane@example.com')
40
+ await wrapper.find('[data-form-field-key="message"] textarea').setValue('hello')
41
+
42
+ await wrapper.find('form').trigger('submit')
43
+ await flushPromises()
44
+
45
+ expect(fetchMock).toHaveBeenCalledTimes(1)
46
+ const [url, init] = fetchMock.mock.calls[0]
47
+ expect(url).toBe('https://api.example.com/sites/kept/forms/contact/submissions')
48
+ expect((init as RequestInit).method).toBe('POST')
49
+ expect(((init as RequestInit).headers as Record<string, string>)['Content-Type']).toBe(
50
+ 'application/json',
51
+ )
52
+ const body = JSON.parse((init as RequestInit).body as string)
53
+ expect(body).toEqual({
54
+ formId: 'contact',
55
+ values: { name: 'Jane', email: 'jane@example.com', message: 'hello' },
56
+ captchaToken: 'tok-abc',
57
+ })
58
+
59
+ expect(wrapper.emitted('submit-success')?.length).toBe(1)
60
+
61
+ ;(globalThis as unknown as { fetch: typeof fetch }).fetch = originalFetch
62
+ })
63
+
64
+ it('emits validation-error when required fields are missing', async () => {
65
+ const wrapper = mount(DcsForm, {
66
+ props: {
67
+ formId: 'contact',
68
+ siteSlug: 'kept',
69
+ definitionOverride: def,
70
+ apiBase: 'https://api.example.com',
71
+ },
72
+ })
73
+ await wrapper.find('form').trigger('submit')
74
+ await flushPromises()
75
+ expect(wrapper.emitted('validation-error')?.length).toBe(1)
76
+ })
77
+
78
+ it('uses multipart form data for file submissions on the managed-form route', async () => {
79
+ const fetchMock = vi.fn().mockResolvedValue({
80
+ ok: true,
81
+ status: 202,
82
+ json: async () => ({ status: 'ok' }),
83
+ })
84
+ const file = new File(['hello'], 'estimate.txt', { type: 'text/plain' })
85
+
86
+ await submitFormValues({
87
+ apiBase: 'https://api.example.com/api/v1/',
88
+ siteSlug: 'handyman-bryan',
89
+ fetchImpl: fetchMock as unknown as typeof fetch,
90
+ payload: {
91
+ formId: 'quote request',
92
+ values: {
93
+ name: 'Jane',
94
+ attachment: file,
95
+ services: ['drywall', 'paint'],
96
+ },
97
+ captchaToken: 'tok-file',
98
+ },
99
+ })
100
+
101
+ expect(fetchMock).toHaveBeenCalledTimes(1)
102
+ const [url, init] = fetchMock.mock.calls[0]
103
+ expect(url).toBe(
104
+ 'https://api.example.com/api/v1/sites/handyman-bryan/forms/quote%20request/submissions',
105
+ )
106
+ expect(((init as RequestInit).headers as Record<string, string> | undefined)?.['Content-Type']).toBeUndefined()
107
+ const body = (init as RequestInit).body as FormData
108
+ expect(body).toBeInstanceOf(FormData)
109
+ expect(body.get('formId')).toBe('quote request')
110
+ expect(body.get('captchaToken')).toBe('tok-file')
111
+ expect(body.get('values[name]')).toBe('Jane')
112
+ expect(body.get('values[attachment]')).toBe(file)
113
+ expect(body.getAll('values[services][]')).toEqual(['drywall', 'paint'])
114
+ })
115
+ })
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { validateField } from '../composables/useFormValidation'
3
+ import type { PortalFormField } from '../types'
4
+
5
+ describe('managed form validation', () => {
6
+ it('treats whitespace-only strings as empty for required fields', () => {
7
+ const field: PortalFormField = {
8
+ id: 'name',
9
+ type: 'text',
10
+ label: 'Name',
11
+ required: true,
12
+ }
13
+
14
+ expect(validateField(field, ' ')).toBe('Name is required')
15
+ })
16
+
17
+ it('runs string length validation against trimmed input', () => {
18
+ const field: PortalFormField = {
19
+ id: 'message',
20
+ type: 'textarea',
21
+ label: 'Message',
22
+ required: true,
23
+ validation: { minLength: 10 },
24
+ }
25
+
26
+ expect(validateField(field, ' ')).toBe('Message is required')
27
+ expect(validateField(field, ' short ')).toBe('Message must be at least 10 characters')
28
+ })
29
+ })