@duffcloudservices/site-forms 0.1.2 → 0.1.4
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 +18 -4
- package/dist/composables/useFormSubmission.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +554 -398
- package/dist/index.js.map +1 -1
- package/dist/presets.d.ts +13 -0
- package/dist/site-forms.css +1 -1
- package/dist/types.d.ts +12 -2
- package/package.json +1 -1
- package/src/DcsForm.vue +1 -9
- package/src/__tests__/fields.test.ts +0 -1
- package/src/__tests__/multi-step.test.ts +0 -1
- package/src/__tests__/presets.test.ts +64 -0
- package/src/__tests__/schema.test.ts +0 -1
- package/src/__tests__/submission.test.ts +40 -2
- package/src/__tests__/validation.test.ts +29 -0
- package/src/__tests__/visible-if.test.ts +1 -2
- package/src/composables/useFormSubmission.ts +3 -3
- package/src/composables/useFormValidation.ts +8 -7
- package/src/index.ts +11 -0
- package/src/presets.ts +192 -0
- package/src/schema/form-definition.schema.json +410 -45
- package/src/style.css +35 -0
- package/src/types.ts +26 -2
|
@@ -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
|
+
})
|
|
@@ -5,7 +5,6 @@ import type { PortalFormDefinition } from '../types'
|
|
|
5
5
|
|
|
6
6
|
const def: PortalFormDefinition = {
|
|
7
7
|
formId: 'gated',
|
|
8
|
-
title: 'Gated',
|
|
9
8
|
submission: { kind: 'lead' },
|
|
10
9
|
fields: [
|
|
11
10
|
{
|
|
@@ -98,7 +97,7 @@ describe('visibleIf', () => {
|
|
|
98
97
|
await flushPromises()
|
|
99
98
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
100
99
|
const [url, init] = fetchMock.mock.calls[0]
|
|
101
|
-
expect(url).toBe('https://api.test/
|
|
100
|
+
expect(url).toBe('https://api.test/sites/site-x/forms/gated/submissions')
|
|
102
101
|
const body = JSON.parse((init as RequestInit).body as string)
|
|
103
102
|
expect(body).toEqual({
|
|
104
103
|
formId: 'gated',
|
|
@@ -16,7 +16,7 @@ export interface SubmitOptions {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* POSTs a form submission to
|
|
19
|
-
* `${apiBase}/
|
|
19
|
+
* `${apiBase}/sites/{siteSlug}/forms/{formId}/submissions`.
|
|
20
20
|
*
|
|
21
21
|
* Uses JSON for plain values and `multipart/form-data` when any
|
|
22
22
|
* value is a `File` (file-upload fields).
|
|
@@ -27,9 +27,9 @@ export async function submitFormValues(
|
|
|
27
27
|
const { apiBase, siteSlug, payload } = opts
|
|
28
28
|
const retries = opts.retries ?? 1
|
|
29
29
|
const fetchImpl = opts.fetchImpl ?? fetch
|
|
30
|
-
const url = `${apiBase.replace(/\/$/, '')}/
|
|
30
|
+
const url = `${apiBase.replace(/\/$/, '')}/sites/${encodeURIComponent(
|
|
31
31
|
siteSlug,
|
|
32
|
-
)}/
|
|
32
|
+
)}/forms/${encodeURIComponent(payload.formId)}/submissions`
|
|
33
33
|
|
|
34
34
|
const hasFile = Object.values(payload.values).some(
|
|
35
35
|
(v) => typeof File !== 'undefined' && v instanceof File,
|
|
@@ -35,10 +35,11 @@ export function validateField(
|
|
|
35
35
|
return undefined
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
const normalizedValue = typeof value === 'string' ? value.trim() : value
|
|
38
39
|
const isEmpty =
|
|
39
40
|
value === undefined ||
|
|
40
41
|
value === null ||
|
|
41
|
-
|
|
42
|
+
normalizedValue === '' ||
|
|
42
43
|
(Array.isArray(value) && value.length === 0)
|
|
43
44
|
|
|
44
45
|
if (field.required && isEmpty) {
|
|
@@ -47,8 +48,8 @@ export function validateField(
|
|
|
47
48
|
if (isEmpty) return undefined
|
|
48
49
|
|
|
49
50
|
// Type-specific built-ins
|
|
50
|
-
if (field.type === 'email' && typeof
|
|
51
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(
|
|
51
|
+
if (field.type === 'email' && typeof normalizedValue === 'string') {
|
|
52
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedValue)) {
|
|
52
53
|
return `${field.label} must be a valid email address`
|
|
53
54
|
}
|
|
54
55
|
}
|
|
@@ -56,16 +57,16 @@ export function validateField(
|
|
|
56
57
|
const v = field.validation
|
|
57
58
|
if (!v) return undefined
|
|
58
59
|
|
|
59
|
-
if (typeof
|
|
60
|
-
if (v.minLength != null &&
|
|
60
|
+
if (typeof normalizedValue === 'string') {
|
|
61
|
+
if (v.minLength != null && normalizedValue.length < v.minLength) {
|
|
61
62
|
return `${field.label} must be at least ${v.minLength} characters`
|
|
62
63
|
}
|
|
63
|
-
if (v.maxLength != null &&
|
|
64
|
+
if (v.maxLength != null && normalizedValue.length > v.maxLength) {
|
|
64
65
|
return `${field.label} must be at most ${v.maxLength} characters`
|
|
65
66
|
}
|
|
66
67
|
if (v.regex) {
|
|
67
68
|
try {
|
|
68
|
-
if (!new RegExp(v.regex).test(
|
|
69
|
+
if (!new RegExp(v.regex).test(normalizedValue)) {
|
|
69
70
|
return `${field.label} is invalid`
|
|
70
71
|
}
|
|
71
72
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,14 @@ export { submitFormValues } from './composables/useFormSubmission'
|
|
|
26
26
|
export type { SubmitOptions } from './composables/useFormSubmission'
|
|
27
27
|
|
|
28
28
|
export { loadFormDefinitions, parseFormYaml } from './loaders/yaml'
|
|
29
|
+
export {
|
|
30
|
+
buildStandardFormDefinition,
|
|
31
|
+
STANDARD_FORM_PRESET_META,
|
|
32
|
+
} from './presets'
|
|
33
|
+
export type {
|
|
34
|
+
StandardFormPreset,
|
|
35
|
+
BuildStandardFormOptions,
|
|
36
|
+
} from './presets'
|
|
29
37
|
export {
|
|
30
38
|
validateFormDefinition,
|
|
31
39
|
warnIfInvalid,
|
|
@@ -40,6 +48,9 @@ export type {
|
|
|
40
48
|
FormValues,
|
|
41
49
|
PortalFormDefinition,
|
|
42
50
|
PortalFormField,
|
|
51
|
+
PortalFormKind,
|
|
52
|
+
PortalFormFieldRole,
|
|
53
|
+
PortalFormAttachmentPolicy,
|
|
43
54
|
PortalFormFieldType,
|
|
44
55
|
PortalFormFieldOption,
|
|
45
56
|
PortalFormFieldValidation,
|
package/src/presets.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { PortalFormDefinition, PortalFormField } from './types'
|
|
2
|
+
|
|
3
|
+
export type StandardFormPreset = 'contact' | 'revenue-contractor' | 'resume-submission' | 'custom'
|
|
4
|
+
|
|
5
|
+
export interface BuildStandardFormOptions {
|
|
6
|
+
formId?: string
|
|
7
|
+
submitLabel?: string
|
|
8
|
+
successMessage?: string
|
|
9
|
+
leadCategory?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const STANDARD_FORM_PRESET_META: Record<StandardFormPreset, { label: string; description: string }> = {
|
|
13
|
+
contact: {
|
|
14
|
+
label: 'Contact',
|
|
15
|
+
description: 'Canonical contact form with standard contact fields.',
|
|
16
|
+
},
|
|
17
|
+
'revenue-contractor': {
|
|
18
|
+
label: 'Revenue contractor',
|
|
19
|
+
description: 'Contact fields plus a required project summary and optional image upload.',
|
|
20
|
+
},
|
|
21
|
+
'resume-submission': {
|
|
22
|
+
label: 'Resume submission',
|
|
23
|
+
description: 'Candidate contact fields plus a required resume upload.',
|
|
24
|
+
},
|
|
25
|
+
custom: {
|
|
26
|
+
label: 'Custom / free-form',
|
|
27
|
+
description: 'Flexible starter that stays fully editable for questionnaires and bespoke intake.',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const buildContactFields = (): PortalFormField[] => [
|
|
32
|
+
{ id: 'name', type: 'text', role: 'contact-name', label: 'Full name', required: true, width: 'full' },
|
|
33
|
+
{ id: 'email', type: 'email', role: 'contact-email', label: 'Email', required: true, width: 'half' },
|
|
34
|
+
{ id: 'phone', type: 'tel', role: 'contact-phone', label: 'Phone', width: 'half' },
|
|
35
|
+
{ id: 'company', type: 'text', role: 'contact-company', label: 'Company', width: 'full' },
|
|
36
|
+
{
|
|
37
|
+
id: 'message',
|
|
38
|
+
type: 'textarea',
|
|
39
|
+
role: 'message',
|
|
40
|
+
label: 'How can we help?',
|
|
41
|
+
required: true,
|
|
42
|
+
width: 'full',
|
|
43
|
+
validation: { minLength: 10, maxLength: 2000 },
|
|
44
|
+
},
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
const buildRevenueContractorFields = (): PortalFormField[] => [
|
|
48
|
+
{ id: 'name', type: 'text', role: 'contact-name', label: 'Full name', required: true, width: 'full' },
|
|
49
|
+
{ id: 'email', type: 'email', role: 'contact-email', label: 'Email', required: true, width: 'half' },
|
|
50
|
+
{ id: 'phone', type: 'tel', role: 'contact-phone', label: 'Phone', required: true, width: 'half' },
|
|
51
|
+
{ id: 'company', type: 'text', role: 'contact-company', label: 'Company', width: 'full' },
|
|
52
|
+
{
|
|
53
|
+
id: 'message',
|
|
54
|
+
type: 'textarea',
|
|
55
|
+
role: 'summary',
|
|
56
|
+
label: 'Project summary',
|
|
57
|
+
helpText: 'Tell us what you want built, repaired, or updated.',
|
|
58
|
+
required: true,
|
|
59
|
+
width: 'full',
|
|
60
|
+
validation: { minLength: 20, maxLength: 4000 },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'project-image',
|
|
64
|
+
type: 'file',
|
|
65
|
+
role: 'image-attachments',
|
|
66
|
+
label: 'Reference image',
|
|
67
|
+
helpText: 'Optional. Upload one photo or inspiration image to help us understand the request.',
|
|
68
|
+
width: 'full',
|
|
69
|
+
validation: { accept: ['image/*'] },
|
|
70
|
+
},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
const buildResumeSubmissionFields = (): PortalFormField[] => [
|
|
74
|
+
{ id: 'name', type: 'text', role: 'contact-name', label: 'Full name', required: true, width: 'full' },
|
|
75
|
+
{ id: 'email', type: 'email', role: 'contact-email', label: 'Email', required: true, width: 'half' },
|
|
76
|
+
{ id: 'phone', type: 'tel', role: 'contact-phone', label: 'Phone', width: 'half' },
|
|
77
|
+
{ id: 'position', type: 'text', label: 'Role you are applying for', width: 'full' },
|
|
78
|
+
{
|
|
79
|
+
id: 'message',
|
|
80
|
+
type: 'textarea',
|
|
81
|
+
role: 'message',
|
|
82
|
+
label: 'Anything else we should know?',
|
|
83
|
+
width: 'full',
|
|
84
|
+
validation: { maxLength: 2000 },
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'resume',
|
|
88
|
+
type: 'file',
|
|
89
|
+
role: 'resume',
|
|
90
|
+
label: 'Resume',
|
|
91
|
+
required: true,
|
|
92
|
+
width: 'full',
|
|
93
|
+
validation: {
|
|
94
|
+
accept: [
|
|
95
|
+
'application/pdf',
|
|
96
|
+
'application/msword',
|
|
97
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const buildCustomFields = (): PortalFormField[] => [
|
|
104
|
+
{ id: 'name', type: 'text', label: 'Full name', required: true, width: 'full' },
|
|
105
|
+
{ id: 'email', type: 'email', label: 'Email', required: true, width: 'half' },
|
|
106
|
+
{ id: 'phone', type: 'tel', label: 'Phone', width: 'half' },
|
|
107
|
+
{
|
|
108
|
+
id: 'message',
|
|
109
|
+
type: 'textarea',
|
|
110
|
+
label: 'Message',
|
|
111
|
+
required: true,
|
|
112
|
+
width: 'full',
|
|
113
|
+
validation: { minLength: 10, maxLength: 2000 },
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
export function buildStandardFormDefinition(
|
|
118
|
+
preset: StandardFormPreset,
|
|
119
|
+
options: BuildStandardFormOptions = {},
|
|
120
|
+
): PortalFormDefinition {
|
|
121
|
+
const resolvedPreset = preset ?? 'custom'
|
|
122
|
+
|
|
123
|
+
switch (resolvedPreset) {
|
|
124
|
+
case 'contact':
|
|
125
|
+
return {
|
|
126
|
+
formId: options.formId ?? 'contact',
|
|
127
|
+
formKind: 'contact',
|
|
128
|
+
submitLabel: options.submitLabel ?? 'Send',
|
|
129
|
+
successMessage: options.successMessage ?? 'Thanks — we’ll be in touch shortly.',
|
|
130
|
+
submission: {
|
|
131
|
+
kind: 'lead',
|
|
132
|
+
...(options.leadCategory ? { category: options.leadCategory } : {}),
|
|
133
|
+
},
|
|
134
|
+
fields: buildContactFields(),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case 'revenue-contractor':
|
|
138
|
+
return {
|
|
139
|
+
formId: options.formId ?? 'contact',
|
|
140
|
+
formKind: 'revenue-contractor',
|
|
141
|
+
submitLabel: options.submitLabel ?? 'Request estimate',
|
|
142
|
+
successMessage: options.successMessage ?? 'Thanks — we’ll review your project and follow up soon.',
|
|
143
|
+
submission: {
|
|
144
|
+
kind: 'lead',
|
|
145
|
+
...(options.leadCategory ? { category: options.leadCategory } : {}),
|
|
146
|
+
},
|
|
147
|
+
attachmentPolicy: {
|
|
148
|
+
expected: true,
|
|
149
|
+
required: false,
|
|
150
|
+
maxFiles: 5,
|
|
151
|
+
accept: ['image/*'],
|
|
152
|
+
},
|
|
153
|
+
fields: buildRevenueContractorFields(),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'resume-submission':
|
|
157
|
+
return {
|
|
158
|
+
formId: options.formId ?? 'resume',
|
|
159
|
+
formKind: 'resume-submission',
|
|
160
|
+
submitLabel: options.submitLabel ?? 'Submit resume',
|
|
161
|
+
successMessage: options.successMessage ?? 'Thanks — your application was received.',
|
|
162
|
+
submission: {
|
|
163
|
+
kind: 'lead',
|
|
164
|
+
...(options.leadCategory ? { category: options.leadCategory } : {}),
|
|
165
|
+
},
|
|
166
|
+
attachmentPolicy: {
|
|
167
|
+
expected: true,
|
|
168
|
+
required: true,
|
|
169
|
+
maxFiles: 1,
|
|
170
|
+
accept: [
|
|
171
|
+
'application/pdf',
|
|
172
|
+
'application/msword',
|
|
173
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
fields: buildResumeSubmissionFields(),
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
default:
|
|
180
|
+
return {
|
|
181
|
+
formId: options.formId ?? 'custom-form',
|
|
182
|
+
formKind: 'freeform',
|
|
183
|
+
submitLabel: options.submitLabel ?? 'Send',
|
|
184
|
+
successMessage: options.successMessage ?? 'Thanks — we received your submission.',
|
|
185
|
+
submission: {
|
|
186
|
+
kind: 'lead',
|
|
187
|
+
...(options.leadCategory ? { category: options.leadCategory } : {}),
|
|
188
|
+
},
|
|
189
|
+
fields: buildCustomFields(),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|