@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,43 +1,43 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue'
3
- import type { PortalFormField } from '../types'
4
- import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
5
-
6
- const props = defineProps<{
7
- field: PortalFormField
8
- modelValue: string | undefined
9
- error?: string
10
- }>()
11
-
12
- const emit = defineEmits<{
13
- 'update:modelValue': [value: string]
14
- blur: []
15
- }>()
16
-
17
- const inputId = computed(() => `field-${props.field.id}`)
18
- </script>
19
-
20
- <template>
21
- <DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
22
- <slot
23
- name="input"
24
- :id="inputId"
25
- :value="modelValue"
26
- :on-input="(e: Event) => emit('update:modelValue', (e.target as HTMLTextAreaElement).value)"
27
- :on-blur="() => emit('blur')"
28
- >
29
- <textarea
30
- :id="inputId"
31
- class="dcs-form-input dcs-form-textarea"
32
- :name="field.id"
33
- :placeholder="field.placeholder"
34
- :required="field.required"
35
- :aria-invalid="!!error"
36
- rows="5"
37
- :value="modelValue ?? ''"
38
- @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
39
- @blur="emit('blur')"
40
- />
41
- </slot>
42
- </DcsFormFieldWrapper>
43
- </template>
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { PortalFormField } from '../types'
4
+ import DcsFormFieldWrapper from './DcsFormFieldWrapper.vue'
5
+
6
+ const props = defineProps<{
7
+ field: PortalFormField
8
+ modelValue: string | undefined
9
+ error?: string
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: string]
14
+ blur: []
15
+ }>()
16
+
17
+ const inputId = computed(() => `field-${props.field.id}`)
18
+ </script>
19
+
20
+ <template>
21
+ <DcsFormFieldWrapper :field="field" :error="error" :input-id="inputId">
22
+ <slot
23
+ name="input"
24
+ :id="inputId"
25
+ :value="modelValue"
26
+ :on-input="(e: Event) => emit('update:modelValue', (e.target as HTMLTextAreaElement).value)"
27
+ :on-blur="() => emit('blur')"
28
+ >
29
+ <textarea
30
+ :id="inputId"
31
+ class="dcs-form-input dcs-form-textarea"
32
+ :name="field.id"
33
+ :placeholder="field.placeholder"
34
+ :required="field.required"
35
+ :aria-invalid="!!error"
36
+ rows="5"
37
+ :value="modelValue ?? ''"
38
+ @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
39
+ @blur="emit('blur')"
40
+ />
41
+ </slot>
42
+ </DcsFormFieldWrapper>
43
+ </template>
package/src/index.ts CHANGED
@@ -1,51 +1,64 @@
1
- export { default as DcsForm } from './DcsForm.vue'
2
- export { default as DcsFormText } from './fields/DcsFormText.vue'
3
- export { default as DcsFormTextarea } from './fields/DcsFormTextarea.vue'
4
- export { default as DcsFormSelect } from './fields/DcsFormSelect.vue'
5
- export { default as DcsFormRadio } from './fields/DcsFormRadio.vue'
6
- export { default as DcsFormCheckboxGroup } from './fields/DcsFormCheckboxGroup.vue'
7
- export { default as DcsFormCheckbox } from './fields/DcsFormCheckbox.vue'
8
- export { default as DcsFormDate } from './fields/DcsFormDate.vue'
9
- export { default as DcsFormFile } from './fields/DcsFormFile.vue'
10
- export { default as DcsFormHidden } from './fields/DcsFormHidden.vue'
11
- export { default as DcsFormSection } from './fields/DcsFormSection.vue'
12
- export { default as DcsFormHtmlBlock } from './fields/DcsFormHtmlBlock.vue'
13
- export { default as DcsFormFieldWrapper } from './fields/DcsFormFieldWrapper.vue'
14
-
15
- export { useDcsForm } from './composables/useDcsForm'
16
- export type { UseDcsFormOptions, UseDcsFormReturn } from './composables/useDcsForm'
17
- export {
18
- validateField,
19
- validateForm,
20
- isFieldVisible,
21
- hasErrors,
22
- } from './composables/useFormValidation'
23
- export { submitFormValues } from './composables/useFormSubmission'
24
- export type { SubmitOptions } from './composables/useFormSubmission'
25
-
26
- export { loadFormDefinitions, parseFormYaml } from './loaders/yaml'
27
- export {
28
- validateFormDefinition,
29
- warnIfInvalid,
30
- } from './schema/validate'
31
- export type { SchemaValidationResult } from './schema/validate'
32
-
33
- export type {
34
- DcsFormSubmitPayload,
35
- DcsFormSubmitSuccess,
36
- DcsFormSubmitError,
37
- FormErrors,
38
- FormValues,
39
- PortalFormDefinition,
40
- PortalFormField,
41
- PortalFormFieldType,
42
- PortalFormFieldOption,
43
- PortalFormFieldValidation,
44
- PortalFormFieldVisibleIf,
45
- PortalFormFieldWidth,
46
- PortalFormStep,
47
- PortalFormSubmissionConfig,
48
- PortalFormSubmissionLeadConfig,
49
- PortalFormSubmissionEmailConfig,
50
- PortalFormSubmissionWebhookConfig,
51
- } from './types'
1
+ import './style.css'
2
+
3
+ export { default as DcsForm } from './DcsForm.vue'
4
+ export { default as DcsFormText } from './fields/DcsFormText.vue'
5
+ export { default as DcsFormTextarea } from './fields/DcsFormTextarea.vue'
6
+ export { default as DcsFormSelect } from './fields/DcsFormSelect.vue'
7
+ export { default as DcsFormRadio } from './fields/DcsFormRadio.vue'
8
+ export { default as DcsFormCheckboxGroup } from './fields/DcsFormCheckboxGroup.vue'
9
+ export { default as DcsFormCheckbox } from './fields/DcsFormCheckbox.vue'
10
+ export { default as DcsFormDate } from './fields/DcsFormDate.vue'
11
+ export { default as DcsFormFile } from './fields/DcsFormFile.vue'
12
+ export { default as DcsFormHidden } from './fields/DcsFormHidden.vue'
13
+ export { default as DcsFormSection } from './fields/DcsFormSection.vue'
14
+ export { default as DcsFormHtmlBlock } from './fields/DcsFormHtmlBlock.vue'
15
+ export { default as DcsFormFieldWrapper } from './fields/DcsFormFieldWrapper.vue'
16
+
17
+ export { useDcsForm } from './composables/useDcsForm'
18
+ export type { UseDcsFormOptions, UseDcsFormReturn } from './composables/useDcsForm'
19
+ export {
20
+ validateField,
21
+ validateForm,
22
+ isFieldVisible,
23
+ hasErrors,
24
+ } from './composables/useFormValidation'
25
+ export { submitFormValues } from './composables/useFormSubmission'
26
+ export type { SubmitOptions } from './composables/useFormSubmission'
27
+
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'
37
+ export {
38
+ validateFormDefinition,
39
+ warnIfInvalid,
40
+ } from './schema/validate'
41
+ export type { SchemaValidationResult } from './schema/validate'
42
+
43
+ export type {
44
+ DcsFormSubmitPayload,
45
+ DcsFormSubmitSuccess,
46
+ DcsFormSubmitError,
47
+ FormErrors,
48
+ FormValues,
49
+ PortalFormDefinition,
50
+ PortalFormField,
51
+ PortalFormKind,
52
+ PortalFormFieldRole,
53
+ PortalFormAttachmentPolicy,
54
+ PortalFormFieldType,
55
+ PortalFormFieldOption,
56
+ PortalFormFieldValidation,
57
+ PortalFormFieldVisibleIf,
58
+ PortalFormFieldWidth,
59
+ PortalFormStep,
60
+ PortalFormSubmissionConfig,
61
+ PortalFormSubmissionLeadConfig,
62
+ PortalFormSubmissionEmailConfig,
63
+ PortalFormSubmissionWebhookConfig,
64
+ } from './types'
@@ -1,51 +1,51 @@
1
- import yaml from 'js-yaml'
2
- import type { PortalFormDefinition } from '../types'
3
-
4
- /**
5
- * Eagerly load every form YAML under `/.dcs/forms/*.yaml` from the
6
- * consuming Vite app and parse them into PortalFormDefinition objects.
7
- *
8
- * Vite returns the modules as raw strings (when using `?raw` or the
9
- * default text loader for unknown extensions) or as parsed objects
10
- * (when `vite-plugin-yaml` is installed). This helper handles both.
11
- */
12
- export function loadFormDefinitions(
13
- modules: Record<string, unknown>,
14
- ): Record<string, PortalFormDefinition> {
15
- const out: Record<string, PortalFormDefinition> = {}
16
- for (const [path, mod] of Object.entries(modules)) {
17
- const formId = extractFormId(path)
18
- const def = parseModule(mod)
19
- if (def) {
20
- out[def.formId ?? formId] = def
21
- }
22
- }
23
- return out
24
- }
25
-
26
- function extractFormId(path: string): string {
27
- const file = path.split('/').pop() ?? path
28
- return file.replace(/\.ya?ml$/i, '')
29
- }
30
-
31
- function parseModule(mod: unknown): PortalFormDefinition | null {
32
- if (!mod) return null
33
- if (typeof mod === 'string') {
34
- return yaml.load(mod) as PortalFormDefinition
35
- }
36
- if (typeof mod === 'object') {
37
- // vite-plugin-yaml returns parsed objects, possibly wrapped in
38
- // `{ default: ... }` when imported via `import: 'default'`.
39
- const maybeDefault = (mod as { default?: unknown }).default
40
- if (maybeDefault && typeof maybeDefault === 'object') {
41
- return maybeDefault as PortalFormDefinition
42
- }
43
- return mod as PortalFormDefinition
44
- }
45
- return null
46
- }
47
-
48
- /** Parse a single raw YAML string into a PortalFormDefinition. */
49
- export function parseFormYaml(raw: string): PortalFormDefinition {
50
- return yaml.load(raw) as PortalFormDefinition
51
- }
1
+ import yaml from 'js-yaml'
2
+ import type { PortalFormDefinition } from '../types'
3
+
4
+ /**
5
+ * Eagerly load every form YAML under `/.dcs/forms/*.yaml` from the
6
+ * consuming Vite app and parse them into PortalFormDefinition objects.
7
+ *
8
+ * Vite returns the modules as raw strings (when using `?raw` or the
9
+ * default text loader for unknown extensions) or as parsed objects
10
+ * (when `vite-plugin-yaml` is installed). This helper handles both.
11
+ */
12
+ export function loadFormDefinitions(
13
+ modules: Record<string, unknown>,
14
+ ): Record<string, PortalFormDefinition> {
15
+ const out: Record<string, PortalFormDefinition> = {}
16
+ for (const [path, mod] of Object.entries(modules)) {
17
+ const formId = extractFormId(path)
18
+ const def = parseModule(mod)
19
+ if (def) {
20
+ out[def.formId ?? formId] = def
21
+ }
22
+ }
23
+ return out
24
+ }
25
+
26
+ function extractFormId(path: string): string {
27
+ const file = path.split('/').pop() ?? path
28
+ return file.replace(/\.ya?ml$/i, '')
29
+ }
30
+
31
+ function parseModule(mod: unknown): PortalFormDefinition | null {
32
+ if (!mod) return null
33
+ if (typeof mod === 'string') {
34
+ return yaml.load(mod) as PortalFormDefinition
35
+ }
36
+ if (typeof mod === 'object') {
37
+ // vite-plugin-yaml returns parsed objects, possibly wrapped in
38
+ // `{ default: ... }` when imported via `import: 'default'`.
39
+ const maybeDefault = (mod as { default?: unknown }).default
40
+ if (maybeDefault && typeof maybeDefault === 'object') {
41
+ return maybeDefault as PortalFormDefinition
42
+ }
43
+ return mod as PortalFormDefinition
44
+ }
45
+ return null
46
+ }
47
+
48
+ /** Parse a single raw YAML string into a PortalFormDefinition. */
49
+ export function parseFormYaml(raw: string): PortalFormDefinition {
50
+ return yaml.load(raw) as PortalFormDefinition
51
+ }
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
+ }