@duffcloudservices/site-forms 0.1.2 → 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.
@@ -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/public/sites/site-x/form-submissions')
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}/public/sites/{siteSlug}/form-submissions`.
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(/\/$/, '')}/public/sites/${encodeURIComponent(
30
+ const url = `${apiBase.replace(/\/$/, '')}/sites/${encodeURIComponent(
31
31
  siteSlug,
32
- )}/form-submissions`
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
- value === '' ||
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 value === 'string') {
51
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
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 value === 'string') {
60
- if (v.minLength != null && value.length < v.minLength) {
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 && value.length > v.maxLength) {
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(value)) {
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
+ }