@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.
@@ -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 !!issue.firstName && !!issue.lastName && !!issue.email && !!issue.issue
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 default async function sendAccessibilityIssue(
10
- issue,
11
- endpoint,
12
- apiKey,
13
- authCode,
14
- options = {}
15
- ) {
16
- if (!issueIsValid(issue)) {
17
- throw new Error('The issue object is incomplete or invalid.')
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 resolvedOptions =
21
- options && typeof options === 'object' && !Array.isArray(options)
22
- ? options
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
- if (!categoryId) {
33
- throw new Error('happyFoxCategoryId is required to send an accessibility issue.')
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
- const payload = {
37
- name: `${issue.firstName} ${issue.lastName}`,
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, payload, {
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
- throw new Error(
63
- `HappyFox error ${error.response.status}: ${JSON.stringify(
53
+ const submissionError = new Error(
54
+ `Accessibility submission error ${error.response.status}: ${JSON.stringify(
64
55
  error.response.data
65
56
  )}`
66
57
  )
67
- } else {
68
- throw new Error(`Request error: ${error.message}`)
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
  }