feat(router): wire portal/register pages, portal-context guard carve-out, lint cleanup
Routing wiring (Phase D of WS-3 PR-B1):
- apps/app/src/plugins/1.router/guards.ts: add a single early-return
carve-out before the org-selection redirect — `if (to.meta.context
=== 'portal') return`. Per ARCH-CONSOLIDATION-2026-04 §4.3,
meta.context is the canonical contract; PR-B2 evolves the guards
from this key to full context-aware logic (post-login landing,
context-switcher, role checks).
- apps/app/env.d.ts: extend RouteMeta with the new layout names
('OrganizerLayout' | 'PortalLayout' | 'PublicLayout'), context,
requiresAuth, requiresToken, navMode, navTitle.
- apps/app/typed-router.d.ts: regenerated by unplugin-vue-router to
pick up portal/* and register/* route names.
- Page meta finalisation: portal pages have layout: 'PortalLayout',
context: 'portal', preserving original requiresAuth + nav fields;
register pages have layout: 'PublicLayout' + public: true (the
apps/app guard convention for public routes, since meta.public is
what the existing guard recognises).
Form-types restructure (boundaries cleanup):
- apps/app/src/composables/forms/types/formBuilder.ts → src/types/forms/
- apps/app/src/composables/forms/utils/{formValidation,validators}.ts
→ src/utils/forms/
- All `@/composables/forms/{types,utils}/*` imports rewritten across
pages, components, composables, tests.
- This avoids a `types → composables` boundaries violation at
src/types/formSchema.ts which re-exports primitives from the
inlined form-schema. types/formSchema.ts now imports from
@/types/forms/formBuilder which is in the same boundaries zone.
Lint cleanup for moved portal sources (apps/portal had no
.eslintrc.cjs; the migrated code now has to pass apps/app's stricter
config):
- axios.isAxiosError → named import { isAxiosError }
(ClaimenTab, RoosterTab, profiel.vue)
- void schemaQuery.refetch() → schemaQuery.refetch()
(register/[public_token].vue)
- if-then-else collapsed to single boolean return (formatFieldValue)
- :delay-on-touch-only="true" → delay-on-touch-only shorthand
(FieldSectionPriority)
- ml-2 class → ms-2 (FieldAvailabilityPicker)
- multi-statement-per-line splits in profiel.vue + spec files
- unused emailConfigured ref removed (profiel.vue)
- one-component-per-file disabled with TODO TECH-WS3-PORTAL-LINT-CLEANUP
ref (FieldOptionsLocale.spec.ts — multi-Wrapper test pattern)
- restored `import Draggable from 'vuedraggable'` after lint:fix
removed it (template-only usage; the import IS needed)
- camelcase param renamed in FieldOptionsLocale harness factory
- typecheck nudge: spec state.data typed via PublicFormSectionOption[] /
PublicFormTimeSlot[] aliases instead of Record<string, unknown>
- PortalLayout.vue: explicit `import { useRoute, useRouter }` so the
vitest mock can intercept (the trimmed AutoImport set doesn't pull
vue-router's auto-imports)
Vitest: 23 / 162 passing. Lint: 0 errors / 0 new warnings (only the
pre-existing boundaries v5→v6 deprecation warnings remain). Typecheck:
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
167
apps/app/src/utils/forms/formValidation.ts
Normal file
167
apps/app/src/utils/forms/formValidation.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { emailValidator, regexValidator, requiredValidator, urlValidator } from './validators'
|
||||
import { FormFieldType } from '@/types/forms/formBuilder'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
export type Validator = (value: unknown) => true | string
|
||||
|
||||
/**
|
||||
* Build a list of client-side validators for a public form field.
|
||||
* Runs in VTextField `:rules` and the stepper "current step valid"
|
||||
* gate. Mirrors (a subset of) the backend relaxed rule set so the
|
||||
* submitter gets feedback before the submit round-trip.
|
||||
*/
|
||||
export function getValidatorsForField(field: PublicFormField): Validator[] {
|
||||
const rules: Validator[] = []
|
||||
const v = field.validation_rules ?? {}
|
||||
|
||||
if (field.is_required) {
|
||||
rules.push(value => {
|
||||
const ok = requiredValidator(value)
|
||||
|
||||
return ok === true ? true : 'Dit veld is verplicht.'
|
||||
})
|
||||
}
|
||||
|
||||
switch (field.field_type) {
|
||||
case FormFieldType.EMAIL:
|
||||
rules.push(value => {
|
||||
const ok = emailValidator(value)
|
||||
|
||||
return ok === true ? true : 'Vul een geldig e-mailadres in.'
|
||||
})
|
||||
break
|
||||
|
||||
case FormFieldType.URL:
|
||||
rules.push(value => {
|
||||
const ok = urlValidator(value)
|
||||
|
||||
return ok === true ? true : 'Vul een geldige URL in (beginnend met http:// of https://).'
|
||||
})
|
||||
break
|
||||
|
||||
case FormFieldType.PHONE:
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
const s = String(value).replace(/\s+/g, '')
|
||||
|
||||
return /^\+?[\d()-]{6,}$/.test(s) || 'Vul een geldig telefoonnummer in.'
|
||||
})
|
||||
break
|
||||
|
||||
case FormFieldType.NUMBER:
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
const n = Number(value)
|
||||
|
||||
return Number.isFinite(n) || 'Vul een geldig getal in.'
|
||||
})
|
||||
if (typeof v.min === 'number') {
|
||||
const min = v.min
|
||||
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
const n = Number(value)
|
||||
|
||||
return Number.isFinite(n) && n >= min ? true : `Minimaal ${min}.`
|
||||
})
|
||||
}
|
||||
if (typeof v.max === 'number') {
|
||||
const max = v.max
|
||||
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
const n = Number(value)
|
||||
|
||||
return Number.isFinite(n) && n <= max ? true : `Maximaal ${max}.`
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case FormFieldType.TEXT:
|
||||
case FormFieldType.TEXTAREA:
|
||||
if (typeof v.min === 'number') {
|
||||
const min = v.min
|
||||
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
|
||||
return String(value).length >= min ? true : `Minimaal ${min} tekens.`
|
||||
})
|
||||
}
|
||||
if (typeof v.max === 'number') {
|
||||
const max = v.max
|
||||
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
|
||||
return String(value).length <= max ? true : `Maximaal ${max} tekens.`
|
||||
})
|
||||
}
|
||||
if (typeof v.pattern === 'string' && v.pattern.length > 0) {
|
||||
const pattern = v.pattern
|
||||
|
||||
rules.push(value => {
|
||||
if (value === null || value === undefined || value === '')
|
||||
return true
|
||||
const ok = regexValidator(value, pattern)
|
||||
|
||||
return ok === true ? true : 'Ongeldige invoer.'
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case FormFieldType.MULTISELECT:
|
||||
case FormFieldType.CHECKBOX_LIST:
|
||||
if (typeof v.min_selections === 'number') {
|
||||
const min = v.min_selections
|
||||
|
||||
rules.push(value => {
|
||||
const arr = Array.isArray(value) ? value : []
|
||||
|
||||
return arr.length >= min ? true : `Kies er minimaal ${min}.`
|
||||
})
|
||||
}
|
||||
if (typeof v.max_selections === 'number') {
|
||||
const max = v.max_selections
|
||||
|
||||
rules.push(value => {
|
||||
const arr = Array.isArray(value) ? value : []
|
||||
|
||||
return arr.length <= max ? true : `Kies er maximaal ${max}.`
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return rules
|
||||
}
|
||||
|
||||
export function runValidators(rules: Validator[], value: unknown): string | true {
|
||||
for (const rule of rules) {
|
||||
const r = rule(value)
|
||||
if (r !== true)
|
||||
return r
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isFieldValueEmpty(value: unknown): boolean {
|
||||
if (value === null || value === undefined)
|
||||
return true
|
||||
if (typeof value === 'string')
|
||||
return value.trim() === ''
|
||||
if (Array.isArray(value))
|
||||
return value.length === 0
|
||||
|
||||
return false
|
||||
}
|
||||
63
apps/app/src/utils/forms/validators.ts
Normal file
63
apps/app/src/utils/forms/validators.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Pure boolean validators used as gates by formValidation.ts. Error
|
||||
// messages (in Dutch) are produced by the caller, not by these validators.
|
||||
// This file deliberately has no external imports — the package must be
|
||||
// standalone and free of cross-app coupling.
|
||||
|
||||
function isNullOrUndefined(value: unknown): boolean {
|
||||
return value === null || value === undefined
|
||||
}
|
||||
|
||||
function isEmptyArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.length === 0
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
if (isNullOrUndefined(value))
|
||||
return true
|
||||
if (typeof value === 'string')
|
||||
return value.trim() === ''
|
||||
if (Array.isArray(value))
|
||||
return value.length === 0
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function requiredValidator(value: unknown): boolean {
|
||||
if (isNullOrUndefined(value) || isEmptyArray(value) || value === false)
|
||||
return false
|
||||
|
||||
return String(value).trim().length > 0
|
||||
}
|
||||
|
||||
export function emailValidator(value: unknown): boolean {
|
||||
if (isEmpty(value))
|
||||
return true
|
||||
|
||||
const re = /^(?:[^<>()[\]\\.,;:\s@"]+(?:\.[^<>()[\]\\.,;:\s@"]+)*|".+")@(?:\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\]|(?:[a-z\-\d]+\.)+[a-z]{2,})$/i
|
||||
|
||||
if (Array.isArray(value))
|
||||
return value.every(val => re.test(String(val)))
|
||||
|
||||
return re.test(String(value))
|
||||
}
|
||||
|
||||
export function urlValidator(value: unknown): boolean {
|
||||
if (isEmpty(value))
|
||||
return true
|
||||
|
||||
const re = /^https?:\/\/[^\s$.?#].\S*$/
|
||||
|
||||
return re.test(String(value))
|
||||
}
|
||||
|
||||
export function regexValidator(value: unknown, regex: RegExp | string): boolean {
|
||||
if (isEmpty(value))
|
||||
return true
|
||||
|
||||
const regEx = typeof regex === 'string' ? new RegExp(regex) : regex
|
||||
|
||||
if (Array.isArray(value))
|
||||
return value.every(val => regexValidator(val, regEx))
|
||||
|
||||
return regEx.test(String(value))
|
||||
}
|
||||
Reference in New Issue
Block a user