@byline/admin 3.12.1 → 3.13.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/dist/fields/field-services-types.d.ts +50 -0
- package/dist/forms/form-context.js +30 -60
- package/dist/forms/form-modals.d.ts +30 -0
- package/dist/forms/form-modals.js +189 -0
- package/dist/forms/form-renderer.d.ts +8 -1
- package/dist/forms/form-renderer.js +31 -365
- package/dist/forms/form-status-display.d.ts +21 -0
- package/dist/forms/form-status-display.js +94 -0
- package/dist/forms/status-transitions.d.ts +23 -0
- package/dist/forms/status-transitions.js +37 -0
- package/dist/forms/status-transitions.test.node.d.ts +8 -0
- package/dist/forms/tree-placement-widget.d.ts +23 -0
- package/dist/forms/tree-placement-widget.js +176 -0
- package/dist/forms/tree-placement-widget.module.js +15 -0
- package/dist/forms/tree-placement-widget_module.css +69 -0
- package/dist/forms/use-form-layout.d.ts +35 -0
- package/dist/forms/use-form-layout.js +77 -0
- package/dist/forms/use-form-layout.test.node.d.ts +8 -0
- package/dist/forms/use-tracked-slot.d.ts +45 -0
- package/dist/forms/use-tracked-slot.js +46 -0
- package/dist/react.d.ts +1 -1
- package/package.json +5 -5
- package/src/fields/field-services-types.ts +50 -0
- package/src/forms/form-context.tsx +36 -92
- package/src/forms/form-modals.tsx +186 -0
- package/src/forms/form-renderer.tsx +42 -387
- package/src/forms/form-status-display.tsx +108 -0
- package/src/forms/status-transitions.test.node.ts +87 -0
- package/src/forms/status-transitions.ts +86 -0
- package/src/forms/tree-placement-widget.module.css +87 -0
- package/src/forms/tree-placement-widget.tsx +202 -0
- package/src/forms/use-form-layout.test.node.ts +82 -0
- package/src/forms/use-form-layout.ts +134 -0
- package/src/forms/use-tracked-slot.ts +101 -0
- package/src/react.ts +6 -0
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
import { describe, expect, it } from 'vitest'
|
|
11
|
+
|
|
12
|
+
import { computeStatusTransitions } from './status-transitions'
|
|
13
|
+
|
|
14
|
+
const draft: WorkflowStatus = { name: 'draft', label: 'Draft' }
|
|
15
|
+
const review: WorkflowStatus = { name: 'review', label: 'Review' }
|
|
16
|
+
const published: WorkflowStatus = { name: 'published', label: 'Published' }
|
|
17
|
+
const flow = [draft, review, published]
|
|
18
|
+
|
|
19
|
+
describe('computeStatusTransitions', () => {
|
|
20
|
+
it('falls back to nextStatus when there is no workflow or no current status', () => {
|
|
21
|
+
expect(computeStatusTransitions(undefined, undefined, published)).toEqual({
|
|
22
|
+
primaryStatus: published,
|
|
23
|
+
secondaryStatuses: [],
|
|
24
|
+
isTerminal: false,
|
|
25
|
+
})
|
|
26
|
+
expect(computeStatusTransitions('draft', [], published)).toEqual({
|
|
27
|
+
primaryStatus: published,
|
|
28
|
+
secondaryStatuses: [],
|
|
29
|
+
isTerminal: false,
|
|
30
|
+
})
|
|
31
|
+
expect(computeStatusTransitions(undefined, flow, published)).toEqual({
|
|
32
|
+
primaryStatus: published,
|
|
33
|
+
secondaryStatuses: [],
|
|
34
|
+
isTerminal: false,
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('exposes no transitions for a single-status workflow', () => {
|
|
39
|
+
expect(computeStatusTransitions('draft', [draft], undefined)).toEqual({
|
|
40
|
+
primaryStatus: undefined,
|
|
41
|
+
secondaryStatuses: [],
|
|
42
|
+
isTerminal: false,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('falls back to nextStatus when the current status is not in the workflow', () => {
|
|
47
|
+
expect(computeStatusTransitions('archived', flow, review)).toEqual({
|
|
48
|
+
primaryStatus: review,
|
|
49
|
+
secondaryStatuses: [],
|
|
50
|
+
isTerminal: false,
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('at the first step: primary is the forward step, no back-steps', () => {
|
|
55
|
+
const result = computeStatusTransitions('draft', flow, review)
|
|
56
|
+
expect(result.primaryStatus).toBe(review)
|
|
57
|
+
expect(result.isTerminal).toBe(false)
|
|
58
|
+
// Forward step is the primary, so it is filtered out of the dropdown.
|
|
59
|
+
expect(result.secondaryStatuses).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('in the middle: primary is forward, dropdown offers reset-to-first', () => {
|
|
63
|
+
const result = computeStatusTransitions('review', flow, published)
|
|
64
|
+
expect(result.primaryStatus).toBe(published)
|
|
65
|
+
expect(result.isTerminal).toBe(false)
|
|
66
|
+
// Reset-to-first (draft) is surfaced; the forward step (published) is the
|
|
67
|
+
// primary and filtered out. The "back one step" is draft itself here, so
|
|
68
|
+
// it is not duplicated.
|
|
69
|
+
expect(result.secondaryStatuses).toEqual([draft])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('at the terminal step: primary is the current status, back-steps in dropdown', () => {
|
|
73
|
+
const result = computeStatusTransitions('published', flow, undefined)
|
|
74
|
+
expect(result.primaryStatus).toBe(published)
|
|
75
|
+
expect(result.isTerminal).toBe(true)
|
|
76
|
+
// Both reset-to-first (draft) and back-one-step (review) are offered.
|
|
77
|
+
expect(result.secondaryStatuses).toEqual([draft, review])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('terminal back-one-step is omitted when the previous is already the first', () => {
|
|
81
|
+
const twoStep = [draft, published]
|
|
82
|
+
const result = computeStatusTransitions('published', twoStep, undefined)
|
|
83
|
+
expect(result.isTerminal).toBe(true)
|
|
84
|
+
// Only reset-to-first (draft); no separate back-one-step since prev === first.
|
|
85
|
+
expect(result.secondaryStatuses).toEqual([draft])
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreePlacementWidget — sidebar widget for placing a document within its
|
|
3
|
+
* collection's single-parent tree (the `tree: true` primitive).
|
|
4
|
+
*
|
|
5
|
+
* Override handles:
|
|
6
|
+
* .byline-form-tree — wrapper div
|
|
7
|
+
* .byline-form-tree-current — current-parent line
|
|
8
|
+
* .byline-form-tree-actions — actions row
|
|
9
|
+
* .byline-form-tree-link — text-button actions (move to top level / remove)
|
|
10
|
+
* .byline-form-tree-error — inline error
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
.tree,
|
|
14
|
+
:global(.byline-form-tree) {
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
17
|
+
gap: var(--spacing-8);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.heading,
|
|
21
|
+
:global(.byline-form-tree-heading) {
|
|
22
|
+
font-size: 0.875rem;
|
|
23
|
+
font-weight: 600;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.current,
|
|
27
|
+
:global(.byline-form-tree-current) {
|
|
28
|
+
font-size: 0.875rem;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.parentLabel {
|
|
32
|
+
color: var(--gray-500);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.parentValue {
|
|
36
|
+
font-weight: 500;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.root {
|
|
40
|
+
font-style: italic;
|
|
41
|
+
font-weight: 400;
|
|
42
|
+
color: var(--gray-500);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.actions,
|
|
46
|
+
:global(.byline-form-tree-actions) {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
flex-wrap: wrap;
|
|
50
|
+
gap: 0.75rem;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.link,
|
|
54
|
+
:global(.byline-form-tree-link) {
|
|
55
|
+
background: none;
|
|
56
|
+
border: none;
|
|
57
|
+
padding: 0;
|
|
58
|
+
color: inherit;
|
|
59
|
+
font-size: 0.8rem;
|
|
60
|
+
text-decoration: underline;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.link:disabled,
|
|
65
|
+
:global(.byline-form-tree-link):disabled {
|
|
66
|
+
opacity: 0.5;
|
|
67
|
+
cursor: default;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.error,
|
|
71
|
+
:global(.byline-form-tree-error) {
|
|
72
|
+
margin: 0;
|
|
73
|
+
font-size: 0.8rem;
|
|
74
|
+
color: var(--danger-600, #c0392b);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.sr-only {
|
|
78
|
+
position: absolute;
|
|
79
|
+
width: 1px;
|
|
80
|
+
height: 1px;
|
|
81
|
+
padding: 0;
|
|
82
|
+
margin: -1px;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
clip: rect(0, 0, 0, 0);
|
|
85
|
+
white-space: nowrap;
|
|
86
|
+
border: 0;
|
|
87
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
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 { useCallback, useEffect, useState } from 'react'
|
|
12
|
+
|
|
13
|
+
import { getCollectionDefinition } from '@byline/core'
|
|
14
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
15
|
+
import { Button } from '@byline/ui/react'
|
|
16
|
+
import cx from 'classnames'
|
|
17
|
+
|
|
18
|
+
import { useBylineFieldServices } from '../fields/field-services-context.js'
|
|
19
|
+
import { RelationPicker } from '../fields/relation/relation-picker.js'
|
|
20
|
+
import styles from './tree-placement-widget.module.css'
|
|
21
|
+
|
|
22
|
+
export interface TreePlacementWidgetProps {
|
|
23
|
+
/** The collection path (`tree: true`). */
|
|
24
|
+
collectionPath: string
|
|
25
|
+
/** The logical id of the document being edited. */
|
|
26
|
+
documentId: string
|
|
27
|
+
/** The collection's `useAsTitle` field, used to label the chosen parent. */
|
|
28
|
+
useAsTitle?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sidebar widget for placing the current document within its collection's
|
|
33
|
+
* single-parent document tree (the `tree: true` primitive — docs/DOCUMENT-TREE.md).
|
|
34
|
+
*
|
|
35
|
+
* The tree is document-grain and **unversioned**, so changes here write
|
|
36
|
+
* immediately (independent of the form's content save). The editor picks a
|
|
37
|
+
* parent through the collection's own relation picker — same search / columns
|
|
38
|
+
* UX as a relation field — or moves the document to the top level; the server
|
|
39
|
+
* enforces the cycle / same-collection invariants and fires the
|
|
40
|
+
* structural-change invalidation event.
|
|
41
|
+
*
|
|
42
|
+
* Renders only in edit mode (placement needs a persisted document) and only when
|
|
43
|
+
* the host wires the tree services. Stable override handle: `.byline-form-tree`.
|
|
44
|
+
*/
|
|
45
|
+
export const TreePlacementWidget = ({
|
|
46
|
+
collectionPath,
|
|
47
|
+
documentId,
|
|
48
|
+
useAsTitle,
|
|
49
|
+
}: TreePlacementWidgetProps) => {
|
|
50
|
+
const { t } = useTranslation('byline-admin')
|
|
51
|
+
const { getTreeAncestors, getTreeParent, placeTreeNode } = useBylineFieldServices()
|
|
52
|
+
const targetDefinition = getCollectionDefinition(collectionPath)
|
|
53
|
+
|
|
54
|
+
const [parent, setParent] = useState<{ id: string; title: string } | null>(null)
|
|
55
|
+
// Whether the document has an edge row at all. `false` = *unplaced* (no row);
|
|
56
|
+
// `true` with a null `parent` = a *root*. Distinguishing the two drives the
|
|
57
|
+
// "Add to tree" (unplaced) vs "Top level" (root) display. Hosts that don't
|
|
58
|
+
// wire `getTreeParent` fall back to `true` — the pre-tri-state behaviour.
|
|
59
|
+
const [placed, setPlaced] = useState(true)
|
|
60
|
+
const [loading, setLoading] = useState(true)
|
|
61
|
+
const [busy, setBusy] = useState(false)
|
|
62
|
+
const [error, setError] = useState<string | null>(null)
|
|
63
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
64
|
+
|
|
65
|
+
const treeServicesReady = getTreeAncestors != null && placeTreeNode != null
|
|
66
|
+
|
|
67
|
+
// Load the document's placement state and current parent on mount. The parent
|
|
68
|
+
// title comes from the (hydrated) ancestor chain; `getTreeParent` (when wired)
|
|
69
|
+
// tells unplaced from root, which the ancestor chain alone cannot.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (getTreeAncestors == null) return
|
|
72
|
+
let cancelled = false
|
|
73
|
+
setLoading(true)
|
|
74
|
+
Promise.all([
|
|
75
|
+
getTreeAncestors({ collection: collectionPath, documentId }),
|
|
76
|
+
getTreeParent?.({ collection: collectionPath, documentId }) ??
|
|
77
|
+
Promise.resolve({ placed: true, parentDocumentId: null }),
|
|
78
|
+
])
|
|
79
|
+
.then(([ancestors, placement]) => {
|
|
80
|
+
if (cancelled) return
|
|
81
|
+
const immediate = ancestors.at(-1)
|
|
82
|
+
setParent(immediate ? { id: immediate.id, title: immediate.title } : null)
|
|
83
|
+
setPlaced(placement.placed)
|
|
84
|
+
})
|
|
85
|
+
.catch(() => {
|
|
86
|
+
if (!cancelled) setError(t('treeWidget.error'))
|
|
87
|
+
})
|
|
88
|
+
.finally(() => {
|
|
89
|
+
if (!cancelled) setLoading(false)
|
|
90
|
+
})
|
|
91
|
+
return () => {
|
|
92
|
+
cancelled = true
|
|
93
|
+
}
|
|
94
|
+
}, [getTreeAncestors, getTreeParent, collectionPath, documentId, t])
|
|
95
|
+
|
|
96
|
+
// Place (or re-parent) the node, optimistically updating the current-parent
|
|
97
|
+
// display and reverting on a server rejection (e.g. a cycle). Any successful
|
|
98
|
+
// placement (including making the node a root) leaves it placed.
|
|
99
|
+
const place = useCallback(
|
|
100
|
+
async (parentDocumentId: string | null, optimistic: { id: string; title: string } | null) => {
|
|
101
|
+
if (placeTreeNode == null || busy) return
|
|
102
|
+
const previousParent = parent
|
|
103
|
+
const previousPlaced = placed
|
|
104
|
+
setError(null)
|
|
105
|
+
setBusy(true)
|
|
106
|
+
setParent(optimistic)
|
|
107
|
+
setPlaced(true)
|
|
108
|
+
try {
|
|
109
|
+
await placeTreeNode({ collection: collectionPath, documentId, parentDocumentId })
|
|
110
|
+
} catch {
|
|
111
|
+
setParent(previousParent)
|
|
112
|
+
setPlaced(previousPlaced)
|
|
113
|
+
setError(t('treeWidget.error'))
|
|
114
|
+
} finally {
|
|
115
|
+
setBusy(false)
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
[placeTreeNode, busy, parent, placed, collectionPath, documentId, t]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const handlePick = useCallback(
|
|
122
|
+
(selection: { targetDocumentId: string; record?: Record<string, any> }) => {
|
|
123
|
+
setPickerOpen(false)
|
|
124
|
+
const title = useAsTitle ? selection.record?.[useAsTitle] : undefined
|
|
125
|
+
place(selection.targetDocumentId, {
|
|
126
|
+
id: selection.targetDocumentId,
|
|
127
|
+
title: typeof title === 'string' && title.length > 0 ? title : selection.targetDocumentId,
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
[place, useAsTitle]
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if (!treeServicesReady) return null
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={cx('byline-form-tree', styles.tree)}>
|
|
137
|
+
<span className={cx('byline-form-tree-heading', styles.heading)}>
|
|
138
|
+
{t('treeWidget.label')}
|
|
139
|
+
</span>
|
|
140
|
+
|
|
141
|
+
<div className={cx('byline-form-tree-current', styles.current)}>
|
|
142
|
+
<span className={styles.parentLabel}>{t('treeWidget.parentPrefix')}</span>{' '}
|
|
143
|
+
{!placed ? (
|
|
144
|
+
<span className={cx(styles.parentValue, styles.root)}>{t('treeWidget.notInTree')}</span>
|
|
145
|
+
) : parent ? (
|
|
146
|
+
<span className={styles.parentValue}>{parent.title}</span>
|
|
147
|
+
) : (
|
|
148
|
+
<span className={cx(styles.parentValue, styles.root)}>{t('treeWidget.rootOption')}</span>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<div className={cx('byline-form-tree-actions', styles.actions)}>
|
|
153
|
+
<Button
|
|
154
|
+
type="button"
|
|
155
|
+
size="xs"
|
|
156
|
+
variant="outlined"
|
|
157
|
+
intent="noeffect"
|
|
158
|
+
disabled={loading || busy}
|
|
159
|
+
onClick={() => setPickerOpen(true)}
|
|
160
|
+
>
|
|
161
|
+
{t('treeWidget.choose')}
|
|
162
|
+
</Button>
|
|
163
|
+
{!placed ? (
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
className={cx('byline-form-tree-link', styles.link)}
|
|
167
|
+
disabled={loading || busy}
|
|
168
|
+
onClick={() => place(null, null)}
|
|
169
|
+
>
|
|
170
|
+
{t('treeWidget.addToTree')}
|
|
171
|
+
</button>
|
|
172
|
+
) : (
|
|
173
|
+
parent != null && (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className={cx('byline-form-tree-link', styles.link)}
|
|
177
|
+
disabled={busy}
|
|
178
|
+
onClick={() => place(null, null)}
|
|
179
|
+
>
|
|
180
|
+
{t('treeWidget.makeRoot')}
|
|
181
|
+
</button>
|
|
182
|
+
)
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{error != null && <p className={cx('byline-form-tree-error', styles.error)}>{error}</p>}
|
|
187
|
+
|
|
188
|
+
{targetDefinition != null && (
|
|
189
|
+
<RelationPicker
|
|
190
|
+
targetCollectionPath={collectionPath}
|
|
191
|
+
targetDefinition={targetDefinition}
|
|
192
|
+
displayField={useAsTitle}
|
|
193
|
+
isOpen={pickerOpen}
|
|
194
|
+
onSelect={handlePick}
|
|
195
|
+
onDismiss={() => setPickerOpen(false)}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
<span className={styles['sr-only']}>{t('treeWidget.srDescription')}</span>
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -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
|
+
}
|