@asd20/ui-next 2.23.5 → 2.24.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/CHANGELOG.md +14 -0
- package/components.js +2 -0
- package/helpers.js +7 -0
- package/package.json +1 -1
- package/src/components/molecules/Asd20PublicFormField/index.vue +215 -0
- package/src/components/organisms/Asd20AccessibilityIssueModal/index.vue +187 -22
- package/src/components/organisms/Asd20PublicFormModal/index.vue +486 -0
- package/src/helpers/publicFormDefinition.js +236 -0
- package/src/helpers/publicForms.js +66 -0
- package/src/helpers/sendAccessibilityIssue.js +42 -49
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
export const SUPPORTED_PUBLIC_FORM_FIELD_TYPES = Object.freeze([
|
|
2
|
+
'text',
|
|
3
|
+
'email',
|
|
4
|
+
'tel',
|
|
5
|
+
'date',
|
|
6
|
+
'textarea',
|
|
7
|
+
'select',
|
|
8
|
+
'radio',
|
|
9
|
+
'checkbox',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
const FIELD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
|
|
13
|
+
const FORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
14
|
+
const OPTION_VALUE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
15
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
16
|
+
const MAX_FIELDS = 50
|
|
17
|
+
const MAX_OPTIONS = 100
|
|
18
|
+
const MAX_TEXT_LENGTH = 10000
|
|
19
|
+
|
|
20
|
+
function boundedText(value, label, maximum, required = false) {
|
|
21
|
+
if (value == null && !required) return ''
|
|
22
|
+
if (typeof value !== 'string') throw new Error(`${label} must be text.`)
|
|
23
|
+
const normalized = value.trim()
|
|
24
|
+
if (required && !normalized) throw new Error(`${label} is required.`)
|
|
25
|
+
if (normalized.length > maximum) throw new Error(`${label} is too long.`)
|
|
26
|
+
return normalized
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionalLength(value, label) {
|
|
30
|
+
if (value == null) return undefined
|
|
31
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_TEXT_LENGTH) {
|
|
32
|
+
throw new Error(`${label} is invalid.`)
|
|
33
|
+
}
|
|
34
|
+
return value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeOptions(field) {
|
|
38
|
+
if (!['select', 'radio'].includes(field.type)) return undefined
|
|
39
|
+
if (
|
|
40
|
+
!Array.isArray(field.options) ||
|
|
41
|
+
!field.options.length ||
|
|
42
|
+
field.options.length > MAX_OPTIONS
|
|
43
|
+
) {
|
|
44
|
+
throw new Error(`${field.label} must have approved choices.`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const seen = new Set()
|
|
48
|
+
return field.options.map(option => {
|
|
49
|
+
const label = boundedText(option?.label, 'Option label', 200, true)
|
|
50
|
+
const value = boundedText(option?.value, 'Option value', 100, true)
|
|
51
|
+
if (!OPTION_VALUE_PATTERN.test(value))
|
|
52
|
+
throw new Error(`${field.label} has an invalid choice ID.`)
|
|
53
|
+
if (seen.has(value))
|
|
54
|
+
throw new Error(`${field.label} has duplicate choices.`)
|
|
55
|
+
seen.add(value)
|
|
56
|
+
return { label, value }
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeField(field) {
|
|
61
|
+
if (!field || typeof field !== 'object' || Array.isArray(field)) {
|
|
62
|
+
throw new Error('The public form contains an invalid field.')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const name = boundedText(field.name, 'Field name', 64, true)
|
|
66
|
+
if (!FIELD_NAME_PATTERN.test(name))
|
|
67
|
+
throw new Error('The public form contains an invalid field name.')
|
|
68
|
+
|
|
69
|
+
const type = boundedText(field.type, 'Field type', 30, true)
|
|
70
|
+
if (!SUPPORTED_PUBLIC_FORM_FIELD_TYPES.includes(type)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`The public form contains an unsupported field type: ${type}.`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const normalized = {
|
|
77
|
+
name,
|
|
78
|
+
type,
|
|
79
|
+
label: boundedText(field.label, 'Field label', 200, true),
|
|
80
|
+
helpText: boundedText(field.helpText, 'Field help text', 2000),
|
|
81
|
+
required: field.required === true,
|
|
82
|
+
minLength: optionalLength(field.minLength, 'Minimum length'),
|
|
83
|
+
maxLength: optionalLength(field.maxLength, 'Maximum length'),
|
|
84
|
+
autocomplete: boundedText(field.autocomplete, 'Autocomplete value', 100),
|
|
85
|
+
options: undefined,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
normalized.minLength != null &&
|
|
90
|
+
normalized.maxLength != null &&
|
|
91
|
+
normalized.minLength > normalized.maxLength
|
|
92
|
+
) {
|
|
93
|
+
throw new Error(`${normalized.label} has invalid length limits.`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
normalized.options = normalizeOptions({
|
|
97
|
+
...field,
|
|
98
|
+
type,
|
|
99
|
+
label: normalized.label,
|
|
100
|
+
})
|
|
101
|
+
return normalized
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function normalizePublicFormDefinition(definition) {
|
|
105
|
+
if (
|
|
106
|
+
!definition ||
|
|
107
|
+
typeof definition !== 'object' ||
|
|
108
|
+
Array.isArray(definition)
|
|
109
|
+
) {
|
|
110
|
+
throw new Error('The public form definition is invalid.')
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
!Array.isArray(definition.fields) ||
|
|
114
|
+
!definition.fields.length ||
|
|
115
|
+
definition.fields.length > MAX_FIELDS
|
|
116
|
+
) {
|
|
117
|
+
throw new Error('The public form must contain approved fields.')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fields = definition.fields.map(normalizeField)
|
|
121
|
+
const names = new Set(fields.map(field => field.name))
|
|
122
|
+
if (names.size !== fields.length)
|
|
123
|
+
throw new Error('The public form contains duplicate field names.')
|
|
124
|
+
|
|
125
|
+
const version = Number(definition.version)
|
|
126
|
+
if (!Number.isInteger(version) || version < 1)
|
|
127
|
+
throw new Error('The public form version is invalid.')
|
|
128
|
+
|
|
129
|
+
const fallbackContact = definition.fallbackContact
|
|
130
|
+
let normalizedFallback
|
|
131
|
+
if (fallbackContact != null) {
|
|
132
|
+
if (typeof fallbackContact !== 'object' || Array.isArray(fallbackContact)) {
|
|
133
|
+
throw new Error('The public form fallback contact is invalid.')
|
|
134
|
+
}
|
|
135
|
+
const email = boundedText(fallbackContact.email, 'Fallback email', 320)
|
|
136
|
+
const phone = boundedText(fallbackContact.phone, 'Fallback phone', 50)
|
|
137
|
+
if (email && !EMAIL_PATTERN.test(email))
|
|
138
|
+
throw new Error('The public form fallback email is invalid.')
|
|
139
|
+
normalizedFallback = { email, phone }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const id = boundedText(definition.id, 'Form ID', 100, true)
|
|
143
|
+
if (!FORM_ID_PATTERN.test(id)) throw new Error('The public form ID is invalid.')
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
version,
|
|
148
|
+
title: boundedText(definition.title, 'Form title', 200, true),
|
|
149
|
+
introduction: boundedText(
|
|
150
|
+
definition.introduction,
|
|
151
|
+
'Form introduction',
|
|
152
|
+
2000
|
|
153
|
+
),
|
|
154
|
+
submitLabel: boundedText(definition.submitLabel, 'Submit label', 100, true),
|
|
155
|
+
successMessage: boundedText(
|
|
156
|
+
definition.successMessage,
|
|
157
|
+
'Success message',
|
|
158
|
+
1000,
|
|
159
|
+
true
|
|
160
|
+
),
|
|
161
|
+
fallbackContact: normalizedFallback,
|
|
162
|
+
fields,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function createPublicFormValues(definition) {
|
|
167
|
+
return Object.fromEntries(
|
|
168
|
+
definition.fields.map(field => [
|
|
169
|
+
field.name,
|
|
170
|
+
field.type === 'checkbox' ? false : '',
|
|
171
|
+
])
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validDate(value) {
|
|
176
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
|
177
|
+
const [year, month, day] = value.split('-').map(Number)
|
|
178
|
+
const date = new Date(Date.UTC(year, month - 1, day))
|
|
179
|
+
return (
|
|
180
|
+
date.getUTCFullYear() === year &&
|
|
181
|
+
date.getUTCMonth() === month - 1 &&
|
|
182
|
+
date.getUTCDate() === day
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function validatePublicFormValues(definition, sourceValues = {}) {
|
|
187
|
+
const errors = {}
|
|
188
|
+
const values = {}
|
|
189
|
+
|
|
190
|
+
for (const field of definition.fields) {
|
|
191
|
+
const rawValue = sourceValues[field.name]
|
|
192
|
+
const fieldErrors = []
|
|
193
|
+
|
|
194
|
+
if (field.type === 'checkbox') {
|
|
195
|
+
const value = rawValue === true
|
|
196
|
+
values[field.name] = value
|
|
197
|
+
if (field.required && !value)
|
|
198
|
+
fieldErrors.push(`${field.label} is required.`)
|
|
199
|
+
} else {
|
|
200
|
+
const hasInvalidType = rawValue != null && typeof rawValue !== 'string'
|
|
201
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
202
|
+
values[field.name] = field.type === 'email' ? value.toLowerCase() : value
|
|
203
|
+
|
|
204
|
+
if (hasInvalidType) fieldErrors.push(`${field.label} must be text.`)
|
|
205
|
+
else if (field.required && !value)
|
|
206
|
+
fieldErrors.push(`${field.label} is required.`)
|
|
207
|
+
if (value && field.minLength != null && value.length < field.minLength) {
|
|
208
|
+
fieldErrors.push(
|
|
209
|
+
`${field.label} must be at least ${field.minLength} characters.`
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
if (value && field.maxLength != null && value.length > field.maxLength) {
|
|
213
|
+
fieldErrors.push(
|
|
214
|
+
`${field.label} must be ${field.maxLength} characters or fewer.`
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
if (value && field.type === 'email' && !EMAIL_PATTERN.test(value)) {
|
|
218
|
+
fieldErrors.push('Enter a valid email address.')
|
|
219
|
+
}
|
|
220
|
+
if (value && field.type === 'date' && !validDate(value)) {
|
|
221
|
+
fieldErrors.push(`Enter a valid ${field.label.toLowerCase()}.`)
|
|
222
|
+
}
|
|
223
|
+
if (value && ['select', 'radio'].includes(field.type)) {
|
|
224
|
+
const allowedValues = new Set(
|
|
225
|
+
(field.options || []).map(option => option.value)
|
|
226
|
+
)
|
|
227
|
+
if (!allowedValues.has(value))
|
|
228
|
+
fieldErrors.push(`Select a valid ${field.label.toLowerCase()}.`)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (fieldErrors.length) errors[field.name] = fieldErrors
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return { values, errors, valid: Object.keys(errors).length === 0 }
|
|
236
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
|
|
3
|
+
import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
|
|
4
|
+
|
|
5
|
+
const PUBLIC_FORMS_ROUTE = '/api/public-forms'
|
|
6
|
+
const PUBLIC_FORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
7
|
+
|
|
8
|
+
function normalizeFormId(formId) {
|
|
9
|
+
const normalized =
|
|
10
|
+
typeof formId === 'string' ? formId.trim().toLowerCase() : ''
|
|
11
|
+
if (!PUBLIC_FORM_ID_PATTERN.test(normalized)) {
|
|
12
|
+
throw new Error('The public form ID is invalid.')
|
|
13
|
+
}
|
|
14
|
+
return normalized
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolvePublicFormsEndpoint({
|
|
18
|
+
endpoint = null,
|
|
19
|
+
functionsEndpoint = null,
|
|
20
|
+
runtimeConfig = {},
|
|
21
|
+
} = {}) {
|
|
22
|
+
const explicitEndpoint = resolveRuntimeConfigValue({
|
|
23
|
+
explicitValue: endpoint,
|
|
24
|
+
runtimeConfig,
|
|
25
|
+
runtimeConfigKeys: ['publicFormsEndpoint'],
|
|
26
|
+
envKeys: ['PUBLIC_FORMS_ENDPOINT'],
|
|
27
|
+
})
|
|
28
|
+
if (explicitEndpoint) return normalizeBaseUrl(explicitEndpoint)
|
|
29
|
+
|
|
30
|
+
const resolvedFunctionsEndpoint = resolveRuntimeConfigValue({
|
|
31
|
+
explicitValue: functionsEndpoint,
|
|
32
|
+
runtimeConfig,
|
|
33
|
+
runtimeConfigKeys: ['functionsEndpoint'],
|
|
34
|
+
envKeys: ['FUNCTIONS_ENDPOINT'],
|
|
35
|
+
})
|
|
36
|
+
const baseUrl = normalizeBaseUrl(resolvedFunctionsEndpoint)
|
|
37
|
+
return baseUrl ? `${baseUrl}${PUBLIC_FORMS_ROUTE}` : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolvePublicFormEndpoints(formId, options = {}) {
|
|
41
|
+
const normalizedFormId = normalizeFormId(formId)
|
|
42
|
+
const baseEndpoint = resolvePublicFormsEndpoint(options)
|
|
43
|
+
if (!baseEndpoint) return { schema: null, submissions: null }
|
|
44
|
+
|
|
45
|
+
const schema = `${baseEndpoint}/${normalizedFormId}`
|
|
46
|
+
return { schema, submissions: `${schema}/submissions` }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function fetchPublicFormDefinition(formId, options = {}) {
|
|
50
|
+
const { schema } = resolvePublicFormEndpoints(formId, options)
|
|
51
|
+
if (!schema) throw new Error('The public forms endpoint is not configured.')
|
|
52
|
+
|
|
53
|
+
const response = await axios.get(schema)
|
|
54
|
+
return response.data
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function submitPublicForm(formId, submission, options = {}) {
|
|
58
|
+
const { submissions } = resolvePublicFormEndpoints(formId, options)
|
|
59
|
+
if (!submissions)
|
|
60
|
+
throw new Error('The public forms endpoint is not configured.')
|
|
61
|
+
|
|
62
|
+
const response = await axios.post(submissions, submission)
|
|
63
|
+
return response.data
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { normalizeFormId }
|
|
@@ -1,71 +1,64 @@
|
|
|
1
1
|
import axios from 'axios'
|
|
2
2
|
|
|
3
|
-
import { resolveRuntimeConfigValue } from './runtimeConfig'
|
|
3
|
+
import { normalizeBaseUrl, resolveRuntimeConfigValue } from './runtimeConfig'
|
|
4
|
+
|
|
5
|
+
const ACCESSIBILITY_ISSUE_ROUTE = '/api/accessibility/issues'
|
|
4
6
|
|
|
5
7
|
function issueIsValid(issue) {
|
|
6
|
-
return
|
|
8
|
+
return Boolean(
|
|
9
|
+
issue &&
|
|
10
|
+
String(issue.firstName || '').trim() &&
|
|
11
|
+
String(issue.lastName || '').trim() &&
|
|
12
|
+
String(issue.email || '').trim() &&
|
|
13
|
+
String(issue.issue || '').trim()
|
|
14
|
+
)
|
|
7
15
|
}
|
|
8
16
|
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
17
|
+
export function resolveAccessibilityIssueEndpoint({
|
|
18
|
+
endpoint = null,
|
|
19
|
+
runtimeConfig = {},
|
|
20
|
+
} = {}) {
|
|
21
|
+
const explicitEndpoint = resolveRuntimeConfigValue({
|
|
22
|
+
explicitValue: endpoint,
|
|
23
|
+
runtimeConfig,
|
|
24
|
+
runtimeConfigKeys: ['accessibilityIssueEndpoint'],
|
|
25
|
+
envKeys: ['ACCESSIBILITY_ISSUE_ENDPOINT'],
|
|
26
|
+
})
|
|
27
|
+
if (explicitEndpoint) return String(explicitEndpoint).trim()
|
|
19
28
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
categoryId: options,
|
|
25
|
-
}
|
|
26
|
-
const categoryId = resolveRuntimeConfigValue({
|
|
27
|
-
explicitValue: resolvedOptions.categoryId,
|
|
28
|
-
runtimeConfig: resolvedOptions.runtimeConfig,
|
|
29
|
-
runtimeConfigKeys: ['happyFoxCategoryId'],
|
|
30
|
-
envKeys: ['HAPPYFOX_CATEGORY_ID'],
|
|
29
|
+
const functionsEndpoint = resolveRuntimeConfigValue({
|
|
30
|
+
runtimeConfig,
|
|
31
|
+
runtimeConfigKeys: ['functionsEndpoint'],
|
|
32
|
+
envKeys: ['FUNCTIONS_ENDPOINT'],
|
|
31
33
|
})
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
const baseUrl = normalizeBaseUrl(functionsEndpoint)
|
|
35
|
+
|
|
36
|
+
return baseUrl ? `${baseUrl}${ACCESSIBILITY_ISSUE_ROUTE}` : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default async function sendAccessibilityIssue(issue, endpoint) {
|
|
40
|
+
if (!issueIsValid(issue)) {
|
|
41
|
+
throw new Error('The issue object is incomplete or invalid.')
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
email: issue.email,
|
|
39
|
-
phone: issue.phone || '',
|
|
40
|
-
subject: 'Accessibility Issue Reported via External ASD20 Website',
|
|
41
|
-
html: `
|
|
42
|
-
<p><strong>Issue Details:</strong></p>
|
|
43
|
-
<p>${issue.issue}</p>
|
|
44
|
-
<p><strong>Mailing Address:</strong> ${issue.address || 'Not provided'}</p>
|
|
45
|
-
<p><strong>Organization:</strong> ${issue.organization || 'Not provided'}</p>
|
|
46
|
-
`,
|
|
47
|
-
category: Number(categoryId),
|
|
44
|
+
if (!endpoint) {
|
|
45
|
+
throw new Error('The accessibility issue endpoint is not configured.')
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
try {
|
|
51
|
-
const response = await axios.post(endpoint,
|
|
52
|
-
headers: {
|
|
53
|
-
Authorization: `Basic ${btoa(`${apiKey}:${authCode}`)}`,
|
|
54
|
-
'Content-Type': 'application/json',
|
|
55
|
-
Accept: 'application/json'
|
|
56
|
-
}
|
|
57
|
-
})
|
|
58
|
-
|
|
49
|
+
const response = await axios.post(endpoint, issue)
|
|
59
50
|
return response.data
|
|
60
51
|
} catch (error) {
|
|
61
52
|
if (error.response) {
|
|
62
|
-
|
|
63
|
-
`
|
|
53
|
+
const submissionError = new Error(
|
|
54
|
+
`Accessibility submission error ${error.response.status}: ${JSON.stringify(
|
|
64
55
|
error.response.data
|
|
65
56
|
)}`
|
|
66
57
|
)
|
|
67
|
-
|
|
68
|
-
throw
|
|
58
|
+
submissionError.response = error.response
|
|
59
|
+
throw submissionError
|
|
69
60
|
}
|
|
61
|
+
|
|
62
|
+
throw new Error(`Accessibility submission request error: ${error.message}`)
|
|
70
63
|
}
|
|
71
64
|
}
|