@byline/admin 3.12.1 → 3.12.2

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,86 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { WorkflowStatus } from '@byline/core'
10
+
11
+ export interface StatusTransitions {
12
+ primaryStatus: WorkflowStatus | undefined
13
+ secondaryStatuses: WorkflowStatus[]
14
+ isTerminal: boolean
15
+ }
16
+
17
+ /**
18
+ * Compute the primary and secondary status transitions for the ComboButton.
19
+ * - Primary: the main action (forward step), or the current status itself
20
+ * when the document has reached the final workflow step (terminal state).
21
+ * - Secondary: other available transitions to show as dropdown options.
22
+ * - isTerminal: true when the document is at the final workflow status —
23
+ * the primary button renders as a non-actionable indicator and all
24
+ * back-steps move into the dropdown.
25
+ */
26
+ export function computeStatusTransitions(
27
+ currentStatus: string | undefined,
28
+ workflowStatuses: WorkflowStatus[] | undefined,
29
+ nextStatus: WorkflowStatus | undefined
30
+ ): StatusTransitions {
31
+ if (!workflowStatuses || workflowStatuses.length === 0 || !currentStatus) {
32
+ return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
33
+ }
34
+
35
+ // Single-status workflows (e.g. SINGLE_STATUS_WORKFLOW for lookups) have
36
+ // no transitions — short-circuit so the form shows only Close / Save.
37
+ if (workflowStatuses.length <= 1) {
38
+ return { primaryStatus: undefined, secondaryStatuses: [], isTerminal: false }
39
+ }
40
+
41
+ const currentIndex = workflowStatuses.findIndex((s) => s.name === currentStatus)
42
+ if (currentIndex === -1) {
43
+ return { primaryStatus: nextStatus, secondaryStatuses: [], isTerminal: false }
44
+ }
45
+
46
+ const isAtEnd = currentIndex === workflowStatuses.length - 1
47
+ const isAtStart = currentIndex === 0
48
+
49
+ // Collect all available target statuses
50
+ const availableTargets: WorkflowStatus[] = []
51
+
52
+ // Reset to first (if not at first)
53
+ if (!isAtStart && workflowStatuses[0]) {
54
+ availableTargets.push(workflowStatuses[0])
55
+ }
56
+
57
+ // Back one step (if not at start and the previous is not already the first)
58
+ const prev = workflowStatuses[currentIndex - 1]
59
+ if (currentIndex > 1 && prev) {
60
+ availableTargets.push(prev)
61
+ }
62
+
63
+ // Forward one step (if not at end) - this is the nextStatus
64
+ const next = workflowStatuses[currentIndex + 1]
65
+ if (!isAtEnd && next) {
66
+ availableTargets.push(next)
67
+ }
68
+
69
+ if (isAtEnd) {
70
+ // Terminal state: the primary button is a non-actionable indicator of the
71
+ // current status; both back-steps (revert to previous / reset to first)
72
+ // are surfaced in the dropdown.
73
+ return {
74
+ primaryStatus: workflowStatuses[currentIndex],
75
+ secondaryStatuses: availableTargets,
76
+ isTerminal: true,
77
+ }
78
+ }
79
+
80
+ // Not at end: primary is the forward step (nextStatus)
81
+ return {
82
+ primaryStatus: nextStatus,
83
+ secondaryStatuses: availableTargets.filter((s) => s.name !== nextStatus?.name),
84
+ isTerminal: false,
85
+ }
86
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { CollectionAdminConfig, Field, GroupDefinition, RowDefinition } from '@byline/core'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import { buildFieldToTabPath } from './use-form-layout'
13
+
14
+ const field = (name: string): Field => ({ name, type: 'text', label: name }) as Field
15
+
16
+ const fieldNames = ['title', 'body', 'seoTitle', 'seoDesc', 'note']
17
+ const fieldByName = new Map<string, Field>(fieldNames.map((n) => [n, field(n)]))
18
+
19
+ const rowByName = new Map<string, RowDefinition>([
20
+ ['seoRow', { name: 'seoRow', fields: ['seoTitle', 'seoDesc'] }],
21
+ ])
22
+ const groupByName = new Map<string, GroupDefinition>([
23
+ ['seoGroup', { name: 'seoGroup', label: 'SEO', fields: ['seoRow'] }],
24
+ ])
25
+
26
+ describe('buildFieldToTabPath', () => {
27
+ it('returns an empty map when there are no tab sets', () => {
28
+ const map = buildFieldToTabPath(undefined, fieldByName, rowByName, groupByName)
29
+ expect(map.size).toBe(0)
30
+ })
31
+
32
+ it('maps direct fields to their tab set + tab', () => {
33
+ const adminConfig = {
34
+ tabSets: [
35
+ {
36
+ name: 'main',
37
+ tabs: [
38
+ { name: 'content', label: 'Content', fields: ['title', 'body'] },
39
+ { name: 'meta', label: 'Meta', fields: ['note'] },
40
+ ],
41
+ },
42
+ ],
43
+ } as unknown as CollectionAdminConfig
44
+
45
+ const map = buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName)
46
+ expect(map.get('title')).toEqual({ tabSetName: 'main', tabName: 'content' })
47
+ expect(map.get('body')).toEqual({ tabSetName: 'main', tabName: 'content' })
48
+ expect(map.get('note')).toEqual({ tabSetName: 'main', tabName: 'meta' })
49
+ })
50
+
51
+ it('recurses through rows and groups to reach nested fields', () => {
52
+ const adminConfig = {
53
+ tabSets: [
54
+ {
55
+ name: 'main',
56
+ tabs: [{ name: 'seo', label: 'SEO', fields: ['seoGroup'] }],
57
+ },
58
+ ],
59
+ } as unknown as CollectionAdminConfig
60
+
61
+ const map = buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName)
62
+ // seoGroup → seoRow → [seoTitle, seoDesc]
63
+ expect(map.get('seoTitle')).toEqual({ tabSetName: 'main', tabName: 'seo' })
64
+ expect(map.get('seoDesc')).toEqual({ tabSetName: 'main', tabName: 'seo' })
65
+ // Container names themselves are not fields, so are not indexed.
66
+ expect(map.has('seoGroup')).toBe(false)
67
+ expect(map.has('seoRow')).toBe(false)
68
+ })
69
+
70
+ it('does not loop forever on a self-referential row cycle', () => {
71
+ const cyclicRows = new Map<string, RowDefinition>([
72
+ ['loop', { name: 'loop', fields: ['loop', 'title'] }],
73
+ ])
74
+ const adminConfig = {
75
+ tabSets: [{ name: 'main', tabs: [{ name: 't', label: 'T', fields: ['loop'] }] }],
76
+ } as unknown as CollectionAdminConfig
77
+
78
+ const map = buildFieldToTabPath(adminConfig, fieldByName, cyclicRows, groupByName)
79
+ // The cycle guard stops the re-visit; the real field is still indexed.
80
+ expect(map.get('title')).toEqual({ tabSetName: 'main', tabName: 't' })
81
+ })
82
+ })
@@ -0,0 +1,134 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useMemo } from 'react'
12
+
13
+ import type {
14
+ CollectionAdminConfig,
15
+ Field,
16
+ GroupDefinition,
17
+ LayoutDefinition,
18
+ RowDefinition,
19
+ TabSetDefinition,
20
+ } from '@byline/core'
21
+
22
+ /** Which tab set + tab a schema field is placed under. */
23
+ export interface TabPath {
24
+ tabSetName: string
25
+ tabName: string
26
+ }
27
+
28
+ /**
29
+ * Derived lookup tables that drive the form's layout walk and per-tab error
30
+ * badges. All are pure derivations of `adminConfig` + `fields`; the startup
31
+ * validator guarantees every reachable name resolves and every schema field is
32
+ * placed at most once, so the consuming render-time lookups stay unguarded.
33
+ */
34
+ export interface FormLayout {
35
+ /** Schema field name → field definition. */
36
+ fieldByName: Map<string, Field>
37
+ tabSetByName: Map<string, TabSetDefinition>
38
+ rowByName: Map<string, RowDefinition>
39
+ groupByName: Map<string, GroupDefinition>
40
+ /** Region placement; synthesised to `{ main: <all fields> }` when omitted. */
41
+ layout: LayoutDefinition
42
+ /** Reverse index: schema field name → which tab set + tab it lives in. */
43
+ fieldToTabPath: Map<string, TabPath>
44
+ }
45
+
46
+ /**
47
+ * Build the reverse index from schema field name to its enclosing tab set +
48
+ * tab. Fields not under any tab set (e.g. raw-field placement directly in
49
+ * `layout.main`) are absent from the map. Rows and groups are recursed into;
50
+ * the `seen` set guards against a config that references a row/group cycle.
51
+ */
52
+ export function buildFieldToTabPath(
53
+ adminConfig: CollectionAdminConfig | undefined,
54
+ fieldByName: Map<string, Field>,
55
+ rowByName: Map<string, RowDefinition>,
56
+ groupByName: Map<string, GroupDefinition>
57
+ ): Map<string, TabPath> {
58
+ const map = new Map<string, TabPath>()
59
+ const visit = (
60
+ names: readonly string[],
61
+ tabSetName: string,
62
+ tabName: string,
63
+ seen: Set<string>
64
+ ) => {
65
+ for (const name of names) {
66
+ if (fieldByName.has(name)) {
67
+ map.set(name, { tabSetName, tabName })
68
+ } else if (seen.has(name)) {
69
+ } else if (rowByName.has(name)) {
70
+ const row = rowByName.get(name)!
71
+ const next = new Set(seen).add(name)
72
+ visit(row.fields, tabSetName, tabName, next)
73
+ } else if (groupByName.has(name)) {
74
+ const group = groupByName.get(name)!
75
+ const next = new Set(seen).add(name)
76
+ visit(group.fields, tabSetName, tabName, next)
77
+ }
78
+ }
79
+ }
80
+ for (const set of adminConfig?.tabSets ?? []) {
81
+ for (const tab of set.tabs) {
82
+ visit(tab.fields, set.name, tab.name, new Set())
83
+ }
84
+ }
85
+ return map
86
+ }
87
+
88
+ /**
89
+ * Memoise the layout primitives + lookup tables the form renderer walks.
90
+ * Rebuilt only when `adminConfig` / `fields` change.
91
+ */
92
+ export function useFormLayout(
93
+ adminConfig: CollectionAdminConfig | undefined,
94
+ fields: Field[]
95
+ ): FormLayout {
96
+ const fieldByName = useMemo(() => {
97
+ const map = new Map<string, Field>()
98
+ for (const field of fields) {
99
+ if ('name' in field) map.set(field.name, field)
100
+ }
101
+ return map
102
+ }, [fields])
103
+
104
+ const tabSetByName = useMemo(() => {
105
+ const map = new Map<string, TabSetDefinition>()
106
+ for (const set of adminConfig?.tabSets ?? []) map.set(set.name, set)
107
+ return map
108
+ }, [adminConfig])
109
+
110
+ const rowByName = useMemo(() => {
111
+ const map = new Map<string, RowDefinition>()
112
+ for (const row of adminConfig?.rows ?? []) map.set(row.name, row)
113
+ return map
114
+ }, [adminConfig])
115
+
116
+ const groupByName = useMemo(() => {
117
+ const map = new Map<string, GroupDefinition>()
118
+ for (const group of adminConfig?.groups ?? []) map.set(group.name, group)
119
+ return map
120
+ }, [adminConfig])
121
+
122
+ // When `layout` is omitted, synthesise main = all schema fields in order.
123
+ const layout = useMemo<LayoutDefinition>(() => {
124
+ if (adminConfig?.layout) return adminConfig.layout
125
+ return { main: fields.filter((f) => 'name' in f).map((f) => (f as { name: string }).name) }
126
+ }, [adminConfig, fields])
127
+
128
+ const fieldToTabPath = useMemo(
129
+ () => buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName),
130
+ [adminConfig, fieldByName, rowByName, groupByName]
131
+ )
132
+
133
+ return { fieldByName, tabSetByName, rowByName, groupByName, layout, fieldToTabPath }
134
+ }
@@ -0,0 +1,101 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { type RefObject, useCallback, useRef } from 'react'
12
+
13
+ /**
14
+ * A dirty-tracked, ref-backed form slot with its own listener set — the shared
15
+ * machinery behind the document-grain system fields (`path`, advertised
16
+ * `availableLocales`, …). Each slot holds the current value, the value it was
17
+ * loaded with, and a set of subscribers; a `set` that moves the value away from
18
+ * its loaded baseline registers the slot's `dirtyKey` in the form's shared
19
+ * dirty set (and clears it again when the value returns to baseline), so the
20
+ * single Save button can branch on which buckets changed.
21
+ *
22
+ * Adding a new system slot is then a one-liner at the call site rather than a
23
+ * copy of ref + initial-ref + listener-set + get/set/subscribe.
24
+ */
25
+ export interface TrackedSlot<T> {
26
+ /** Current value (live ref read — not React state). */
27
+ get: () => T
28
+ /** Write a value; toggles `dirtyKey` against the loaded baseline. */
29
+ set: (value: T) => void
30
+ /** Subscribe to value changes; returns an unsubscribe fn. */
31
+ subscribe: (listener: (value: T) => void) => () => void
32
+ /** Re-baseline: adopt the current value as the new "clean" baseline (called on save). */
33
+ commitInitial: () => void
34
+ }
35
+
36
+ export interface UseTrackedSlotConfig<T> {
37
+ /** Initial / loaded value. */
38
+ initial: T
39
+ /** Key registered in the shared dirty set while this slot diverges from baseline. */
40
+ dirtyKey: string
41
+ /** The form's shared dirty-key set. */
42
+ dirtyFields: RefObject<Set<string>>
43
+ /** Notify the form's meta listeners (drives hasChanges → Save button). */
44
+ notifyMeta: () => void
45
+ /** Equality against the baseline. Defaults to `===` (identity). */
46
+ isEqual?: (a: T, b: T) => boolean
47
+ /** Defensive copy on read-in / write. Defaults to identity (fine for immutables). */
48
+ clone?: (value: T) => T
49
+ }
50
+
51
+ export function useTrackedSlot<T>(config: UseTrackedSlotConfig<T>): TrackedSlot<T> {
52
+ // Snapshot the config on every render into a ref so the returned callbacks
53
+ // can stay referentially stable (empty deps) while still reading the latest
54
+ // isEqual / clone / notifyMeta — matching the stable identities the previous
55
+ // inline implementation relied on.
56
+ const cfgRef = useRef(config)
57
+ cfgRef.current = config
58
+
59
+ const clone = useCallback((value: T): T => {
60
+ const fn = cfgRef.current.clone
61
+ return fn ? fn(value) : value
62
+ }, [])
63
+
64
+ const valueRef = useRef<T>(clone(config.initial))
65
+ const initialRef = useRef<T>(clone(config.initial))
66
+ const listeners = useRef<Set<(value: T) => void>>(new Set())
67
+
68
+ const get = useCallback(() => valueRef.current, [])
69
+
70
+ const set = useCallback(
71
+ (value: T) => {
72
+ const { dirtyKey, dirtyFields, notifyMeta, isEqual } = cfgRef.current
73
+ const next = clone(value)
74
+ valueRef.current = next
75
+ const equal = isEqual ? isEqual(next, initialRef.current) : next === initialRef.current
76
+ if (equal) {
77
+ dirtyFields.current.delete(dirtyKey)
78
+ } else {
79
+ dirtyFields.current.add(dirtyKey)
80
+ }
81
+ listeners.current.forEach((listener) => {
82
+ listener(next)
83
+ })
84
+ notifyMeta()
85
+ },
86
+ [clone]
87
+ )
88
+
89
+ const subscribe = useCallback((listener: (value: T) => void) => {
90
+ listeners.current.add(listener)
91
+ return () => {
92
+ listeners.current.delete(listener)
93
+ }
94
+ }, [])
95
+
96
+ const commitInitial = useCallback(() => {
97
+ initialRef.current = clone(valueRef.current)
98
+ }, [clone])
99
+
100
+ return { get, set, subscribe, commitInitial }
101
+ }