- cabana.PermissionRecordLock and the optional PermissionRecordLockProvider - RecordMeta.PermissionLocks (meta.permissionLocks) on show and save responses, notes localized - save rule: a record-locked code keeps its stored value, a differing present value is 403 - Roles lock code-declared grants (locked_by_code); Administrators lock every override on your own record (locked_self) - SPA: FORM_PERMISSION_LOCKS, locked codes never sent, record-locked rows in the permission editor - regenerated admin OpenAPI and schema.d.ts; docs and README
369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
// Helpers shared by the record form and the settings form: context
|
|
// filtering, the save payload, 422 detail mapping and focus.
|
|
import type { AdminRecord, ErrorBody, FieldNotice, FormField, PermissionRecordLock } from '../../api/types'
|
|
import { permissionValues } from './control'
|
|
import { isMLField, localeRecord } from './mlLocale'
|
|
import { isRegisteredField } from './registry'
|
|
|
|
export { ML_LOCALE_CHANGE, broadcastMLLocale, isMLField, isMLFieldType, localeRecord } from './mlLocale'
|
|
|
|
/** The screen a field is filtered for: the two form modes and the read-only preview (D-11). */
|
|
export type FormMode = 'create' | 'update' | 'preview'
|
|
|
|
/** One form tab: the YAML tab label, or the default tab for untabbed fields. */
|
|
export interface TabItem {
|
|
key: string
|
|
label: string
|
|
errors: number
|
|
}
|
|
|
|
/** Key of the tab that holds fields without a `tab`. */
|
|
export const DEFAULT_TAB = 'default'
|
|
|
|
/** Tab key of a field: its tab label, or the default tab. */
|
|
export function tabOf(field: FormField): string {
|
|
return field.tab ? `tab:${field.tab}` : DEFAULT_TAB
|
|
}
|
|
|
|
/** DOM ids of the tab button and its panel, by tab position. */
|
|
export function tabDomId(prefix: string, index: number): string {
|
|
return `${prefix}-tab-${index}`
|
|
}
|
|
|
|
export function panelDomId(prefix: string, index: number): string {
|
|
return `${prefix}-panel-${index}`
|
|
}
|
|
|
|
/** Winter `context:` semantics: no context shows the field everywhere. */
|
|
export function contextAllows(field: FormField, mode: FormMode): boolean {
|
|
const context = field.context
|
|
if (context === undefined || context === null) {
|
|
return true
|
|
}
|
|
const values = Array.isArray(context) ? context : [context]
|
|
return values.length === 0 || values.includes(mode)
|
|
}
|
|
|
|
/**
|
|
* The save body: values keyed by field name for every editable field that
|
|
* has a value. Read-only fields and types without a renderer are never sent.
|
|
* An empty password on update means "unchanged" and is left out; on create it
|
|
* is sent as entered and the server's rules decide (UI-SPEC S7). `locks` are
|
|
* the record's permission locks (meta.permissionLocks): their codes are left
|
|
* out of each permissioneditor value.
|
|
*/
|
|
export function editablePayload(
|
|
fields: FormField[],
|
|
values: AdminRecord,
|
|
mode: FormMode = 'create',
|
|
locks?: Readonly<Record<string, readonly PermissionRecordLock[]>>,
|
|
): AdminRecord {
|
|
const out: AdminRecord = {}
|
|
for (const field of fields) {
|
|
if (field.readOnly || !isRegisteredField(field)) {
|
|
continue
|
|
}
|
|
const value = values[field.name]
|
|
if (field.type === 'password' && mode === 'update' && (value === '' || value === null)) {
|
|
continue
|
|
}
|
|
if (field.type === 'permissioneditor') {
|
|
// Sent whenever it is shown, reduced to offered codes and values.
|
|
const locked = locks?.[field.name]
|
|
out[field.name] = permissionValues(field, value, locked ? new Set(locked.map((lock) => lock.code)) : undefined)
|
|
continue
|
|
}
|
|
if (isMLField(field)) {
|
|
out[field.name] = localeRecord(value)
|
|
continue
|
|
}
|
|
if (value !== undefined) {
|
|
out[field.name] = value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* The value a preset field takes from its source (fields.yaml `preset`). Type
|
|
* slug: lower-case ASCII letters and digits, every run of other characters
|
|
* one hyphen, no hyphen at either end. Type exact: the same text.
|
|
*/
|
|
export function presetValue(type: string, source: unknown): string {
|
|
const text = typeof source === 'string' || typeof source === 'number' ? String(source) : ''
|
|
if (type !== 'slug') {
|
|
return text
|
|
}
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
}
|
|
|
|
/** The locales whose text differs between two locale maps: every locale on
|
|
* either side, a missing key counting as ''. A value that is not a map counts
|
|
* as an empty map. */
|
|
export function changedLocales(previous: unknown, next: unknown): string[] {
|
|
const before = localeRecord(previous)
|
|
const after = localeRecord(next)
|
|
const out: string[] = []
|
|
for (const locale of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
if ((before[locale] ?? '') !== (after[locale] ?? '')) {
|
|
out.push(locale)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Reports whether the administrator edited a preset target by hand: the
|
|
* whole field when no locale is given, one locale of an ML target otherwise. */
|
|
export type PresetEdited = (target: string, locale?: string) => boolean
|
|
|
|
/**
|
|
* The preset targets of `source` (fields.yaml `preset`) to rewrite after the
|
|
* source changed from `previous` to `next`: target name -> new value. Targets
|
|
* are text and mltext fields; a preset on any other type is ignored.
|
|
*
|
|
* - text -> text: the target takes presetValue of the source, until edited.
|
|
* - ML -> text: the target takes presetValue of the source's active-locale text.
|
|
* - text -> ML: only the target's active locale is written; its other locales stay.
|
|
* - ML -> ML: per locale. Each locale whose source text changed rewrites the
|
|
* same locale of the target, unless the administrator edited that locale by
|
|
* hand; the target's other locales stay.
|
|
*
|
|
* A preset never writes a locale the administrator neither typed in (source)
|
|
* nor sees (target), so untouched locales keep the server's fallback.
|
|
*/
|
|
export function presetUpdates(
|
|
fields: FormField[],
|
|
source: string,
|
|
previous: unknown,
|
|
next: unknown,
|
|
values: AdminRecord,
|
|
activeLocale: string,
|
|
isEdited: PresetEdited,
|
|
): AdminRecord {
|
|
const out: AdminRecord = {}
|
|
const sourceML = isMLField(fields.find((field) => field.name === source))
|
|
for (const field of fields) {
|
|
const preset = field.preset
|
|
if (!preset || preset.field !== source || (field.type !== 'text' && field.type !== 'mltext')) {
|
|
continue
|
|
}
|
|
if (field.type === 'text') {
|
|
if (isEdited(field.name)) {
|
|
continue
|
|
}
|
|
out[field.name] = presetValue(preset.type, sourceML ? (localeRecord(next)[activeLocale] ?? '') : next)
|
|
continue
|
|
}
|
|
if (!sourceML) {
|
|
if (isEdited(field.name, activeLocale)) {
|
|
continue
|
|
}
|
|
out[field.name] = { ...localeRecord(values[field.name]), [activeLocale]: presetValue(preset.type, next) }
|
|
continue
|
|
}
|
|
const texts = localeRecord(next)
|
|
const target = { ...localeRecord(values[field.name]) }
|
|
let written = false
|
|
for (const locale of changedLocales(previous, next)) {
|
|
if (isEdited(field.name, locale)) {
|
|
continue
|
|
}
|
|
target[locale] = presetValue(preset.type, texts[locale] ?? '')
|
|
written = true
|
|
}
|
|
if (written) {
|
|
out[field.name] = target
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Initial values of a new record: schema defaults, toggles off, no ids.
|
|
* ML fields seed an empty string for every enabled locale. */
|
|
export function initialValues(fields: FormField[], enabledLocales: readonly string[] = []): AdminRecord {
|
|
const out: AdminRecord = {}
|
|
for (const field of fields) {
|
|
if (field.default !== undefined) {
|
|
out[field.name] = field.default
|
|
} else if (field.type === 'switch' || field.type === 'checkbox') {
|
|
out[field.name] = false
|
|
} else if (field.type === 'relation' && field.multiple) {
|
|
out[field.name] = []
|
|
} else if (isMLField(field)) {
|
|
const seed: Record<string, string> = {}
|
|
for (const code of enabledLocales) {
|
|
seed[code] = ''
|
|
}
|
|
out[field.name] = seed
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Content locales from form schema meta; empty when the writer is unpublished. */
|
|
export function schemaEnabledLocales(meta: { enabledLocales?: readonly string[] } | null | undefined): readonly string[] {
|
|
return meta?.enabledLocales ?? []
|
|
}
|
|
|
|
/** Merge a GET/save ML value onto a seed map of every enabled locale. A host
|
|
* string becomes the first enabled locale; it never replaces sibling keys. */
|
|
export function mergeMLValue(incoming: unknown, enabledLocales: readonly string[]): Record<string, string> {
|
|
const seeded: Record<string, string> = {}
|
|
for (const code of enabledLocales) {
|
|
seeded[code] = ''
|
|
}
|
|
if (typeof incoming === 'string') {
|
|
const fallback = enabledLocales[0]
|
|
if (fallback) {
|
|
seeded[fallback] = incoming
|
|
}
|
|
return seeded
|
|
}
|
|
return { ...seeded, ...localeRecord(incoming) }
|
|
}
|
|
|
|
/** The record's display name: the value of the first text-like field (plain
|
|
* `text` or an ML type) in schema order. An ML value reads the default (first
|
|
* enabled) locale, then the first non-empty locale; '' when nothing is set. */
|
|
export function recordDisplayName(
|
|
fields: readonly FormField[] | undefined,
|
|
values: AdminRecord,
|
|
enabledLocales: readonly string[],
|
|
): string {
|
|
const first = fields?.find((field) => field.type === 'text' || isMLField(field))
|
|
const value = first ? values[first.name] : undefined
|
|
if (typeof value === 'string' || typeof value === 'number') {
|
|
return String(value).trim()
|
|
}
|
|
if (!first || !isMLField(first)) {
|
|
return ''
|
|
}
|
|
const texts = localeRecord(value)
|
|
const preferred = enabledLocales[0] !== undefined ? (texts[enabledLocales[0]] ?? '').trim() : ''
|
|
if (preferred !== '') {
|
|
return preferred
|
|
}
|
|
for (const text of Object.values(texts)) {
|
|
if (text.trim() !== '') {
|
|
return text.trim()
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/**
|
|
* A save's meta.notices (D-10, 16.2) as field name -> lines shown under the
|
|
* field: a notice about one content locale reads "<LOCALE>: message". Absent
|
|
* or empty notices give an empty map.
|
|
*/
|
|
export function noticeLines(notices: readonly FieldNotice[] | null | undefined): Record<string, string[]> {
|
|
const out: Record<string, string[]> = {}
|
|
for (const notice of notices ?? []) {
|
|
if (!notice || typeof notice.message !== 'string' || notice.message === '' || typeof notice.field !== 'string') {
|
|
continue
|
|
}
|
|
const line = notice.locale ? `${notice.locale.toUpperCase()}: ${notice.message}` : notice.message
|
|
;(out[notice.field] ??= []).push(line)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** D-10 422 details (field -> messages) as a string-list map. */
|
|
export function fieldErrors(details: ErrorBody['details'] | undefined): Record<string, string[]> {
|
|
const out: Record<string, string[]> = {}
|
|
for (const [name, value] of Object.entries(details ?? {})) {
|
|
const messages = Array.isArray(value)
|
|
? value.filter((item): item is string => typeof item === 'string' && item !== '')
|
|
: typeof value === 'string' && value !== ''
|
|
? [value]
|
|
: []
|
|
if (messages.length > 0) {
|
|
out[name] = messages
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** Stable serialization for dirty checks. */
|
|
export function snapshot(record: AdminRecord): string {
|
|
return JSON.stringify(Object.keys(record).sort().map((key) => [key, record[key]]))
|
|
}
|
|
|
|
/** Focuses a field's control (FormField ids are `${prefix}-${name}`). */
|
|
export function focusField(prefix: string, name: string): boolean {
|
|
const element = document.getElementById(`${prefix}-${name}`)
|
|
if (!element) {
|
|
return false
|
|
}
|
|
if (!element.matches('input, select, textarea, button, [tabindex]')) {
|
|
element.setAttribute('tabindex', '-1')
|
|
}
|
|
element.focus()
|
|
return true
|
|
}
|
|
|
|
/** The top-level field of an error or notice key: `content` for `content.3.answer`. */
|
|
export function keyField(key: string): string {
|
|
const dot = key.indexOf('.')
|
|
return dot < 0 ? key : key.slice(0, dot)
|
|
}
|
|
|
|
/** Whether a field has errors on itself or, for a repeater, on any row path. */
|
|
export function fieldHasErrors(errors: Record<string, string[]>, name: string): boolean {
|
|
return Object.keys(errors).some((key) => key === name || key.startsWith(`${name}.`))
|
|
}
|
|
|
|
/** The entries of one repeater row (`<field>.<index>.<nested>`) keyed by nested name. */
|
|
export function rowEntries(map: Record<string, string[]>, field: string, index: number): Record<string, string[]> {
|
|
const prefix = `${field}.${index}.`
|
|
const out: Record<string, string[]> = {}
|
|
for (const [key, lines] of Object.entries(map)) {
|
|
if (key.startsWith(prefix) && key.length > prefix.length) {
|
|
out[key.slice(prefix.length)] = lines
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** The first error key of a field in the server's order: itself, else its first row path. */
|
|
export function firstErrorKey(errors: Record<string, string[]>, name: string): string | undefined {
|
|
if (errors[name]) {
|
|
return name
|
|
}
|
|
return Object.keys(errors).find((key) => key.startsWith(`${name}.`))
|
|
}
|
|
|
|
const NAME = /^[A-Za-z0-9_-]+$/
|
|
|
|
/**
|
|
* Focuses the control of an error key. A row path (`content.3.answer`)
|
|
* focuses the nested control of that repeater row, found through the row's
|
|
* data attributes and the nested label's `for`; a plain name is focusField.
|
|
*/
|
|
export function focusError(prefix: string, key: string): boolean {
|
|
const parts = key.split('.')
|
|
if (parts.length < 3) {
|
|
return focusField(prefix, parts[0] ?? key)
|
|
}
|
|
const [field, index, nested] = parts as [string, string, string]
|
|
if (!NAME.test(field) || !/^\d+$/.test(index) || !NAME.test(nested)) {
|
|
return focusField(prefix, field)
|
|
}
|
|
const cell = document.querySelector(`[data-repeater="${field}"] [data-repeater-row="${index}"] [data-field="${nested}"]`)
|
|
const target = cell?.querySelector('label[for]')?.getAttribute('for')
|
|
const control =
|
|
(target ? document.getElementById(target) : null) ??
|
|
cell?.querySelector<HTMLElement>('input, select, textarea, [contenteditable="true"], button')
|
|
if (!control) {
|
|
return focusField(prefix, field)
|
|
}
|
|
if (!control.matches('input, select, textarea, button, [tabindex], [contenteditable="true"]')) {
|
|
control.setAttribute('tabindex', '-1')
|
|
}
|
|
control.focus()
|
|
return true
|
|
}
|