@byline/admin 5.1.2 → 5.1.4
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/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- package/package.json +7 -7
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) Infonomic Company Limited
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
10
|
+
import { getCollectionDefinition, isSingleton } from '@byline/core';
|
|
11
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
12
|
+
import { Button } from '@byline/ui/react';
|
|
13
|
+
import cx from 'clsx';
|
|
14
|
+
import { useBylineFieldServices } from '../fields/field-services-context.js';
|
|
15
|
+
import { RelationPicker } from '../fields/relation/relation-picker.js';
|
|
16
|
+
import styles from './tree-placement-widget.module.css';
|
|
17
|
+
/**
|
|
18
|
+
* Sidebar widget for placing the current document within its collection's
|
|
19
|
+
* single-parent document tree (the `tree: true` primitive — docs/04-collections/04-document-trees.md).
|
|
20
|
+
*
|
|
21
|
+
* The tree is document-grain and **unversioned**, so changes here write
|
|
22
|
+
* immediately (independent of the form's content save). The editor picks a
|
|
23
|
+
* parent through the collection's own relation picker — same search / columns
|
|
24
|
+
* UX as a relation field — or moves the document to the top level; the server
|
|
25
|
+
* enforces the cycle / same-collection invariants and fires the
|
|
26
|
+
* structural-change invalidation event.
|
|
27
|
+
*
|
|
28
|
+
* Renders only in edit mode (placement needs a persisted document) and only when
|
|
29
|
+
* the host wires the tree services. Stable override handle: `.byline-form-tree`.
|
|
30
|
+
*/
|
|
31
|
+
export const TreePlacementWidget = ({ collectionPath, disabled = false, onMutationError, onCommitted, expectedRevision, documentId, useAsTitle, }) => {
|
|
32
|
+
const { t } = useTranslation('byline-admin');
|
|
33
|
+
const { getTreeAncestors, getTreeParent, placeTreeNode } = useBylineFieldServices();
|
|
34
|
+
const definition = getCollectionDefinition(collectionPath);
|
|
35
|
+
const targetDefinition = definition != null && !isSingleton(definition) ? definition : null;
|
|
36
|
+
const [parent, setParent] = useState(null);
|
|
37
|
+
// Whether the document has an edge row at all. `false` = *unplaced* (no row);
|
|
38
|
+
// `true` with a null `parent` = a *root*. Distinguishing the two drives the
|
|
39
|
+
// "Add to tree" (unplaced) vs "Top level" (root) display. Hosts that don't
|
|
40
|
+
// wire `getTreeParent` fall back to `true` — the pre-tri-state behaviour.
|
|
41
|
+
const [placed, setPlaced] = useState(true);
|
|
42
|
+
const [loading, setLoading] = useState(true);
|
|
43
|
+
const [busy, setBusy] = useState(false);
|
|
44
|
+
const [error, setError] = useState(null);
|
|
45
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
46
|
+
const treeServicesReady = getTreeAncestors != null && placeTreeNode != null;
|
|
47
|
+
// Load the document's placement state and current parent on mount. The parent
|
|
48
|
+
// title comes from the (hydrated) ancestor chain; `getTreeParent` (when wired)
|
|
49
|
+
// tells unplaced from root, which the ancestor chain alone cannot.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (getTreeAncestors == null)
|
|
52
|
+
return;
|
|
53
|
+
let cancelled = false;
|
|
54
|
+
setLoading(true);
|
|
55
|
+
Promise.all([
|
|
56
|
+
getTreeAncestors({ collection: collectionPath, documentId }),
|
|
57
|
+
getTreeParent?.({ collection: collectionPath, documentId }) ??
|
|
58
|
+
Promise.resolve({ placed: true, parentDocumentId: null }),
|
|
59
|
+
])
|
|
60
|
+
.then(([ancestors, placement]) => {
|
|
61
|
+
if (cancelled)
|
|
62
|
+
return;
|
|
63
|
+
const immediate = ancestors.at(-1);
|
|
64
|
+
setParent(immediate ? { id: immediate.id, title: immediate.title } : null);
|
|
65
|
+
setPlaced(placement.placed);
|
|
66
|
+
})
|
|
67
|
+
.catch(() => {
|
|
68
|
+
if (!cancelled)
|
|
69
|
+
setError(t('treeWidget.error'));
|
|
70
|
+
})
|
|
71
|
+
.finally(() => {
|
|
72
|
+
if (!cancelled)
|
|
73
|
+
setLoading(false);
|
|
74
|
+
});
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, [getTreeAncestors, getTreeParent, collectionPath, documentId, t]);
|
|
79
|
+
// Place (or re-parent) the node, optimistically updating the current-parent
|
|
80
|
+
// display and reverting on a server rejection (e.g. a cycle). Any successful
|
|
81
|
+
// placement (including making the node a root) leaves it placed.
|
|
82
|
+
const place = useCallback(async (parentDocumentId, optimistic) => {
|
|
83
|
+
if (disabled || placeTreeNode == null || busy)
|
|
84
|
+
return;
|
|
85
|
+
const previousParent = parent;
|
|
86
|
+
const previousPlaced = placed;
|
|
87
|
+
setError(null);
|
|
88
|
+
setBusy(true);
|
|
89
|
+
setParent(optimistic);
|
|
90
|
+
setPlaced(true);
|
|
91
|
+
try {
|
|
92
|
+
const result = await placeTreeNode({
|
|
93
|
+
expectedRevision,
|
|
94
|
+
collection: collectionPath,
|
|
95
|
+
documentId,
|
|
96
|
+
parentDocumentId,
|
|
97
|
+
});
|
|
98
|
+
onCommitted?.(result);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (onMutationError?.(error) === 'committed')
|
|
102
|
+
return;
|
|
103
|
+
setParent(previousParent);
|
|
104
|
+
setPlaced(previousPlaced);
|
|
105
|
+
setError(t('treeWidget.error'));
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
setBusy(false);
|
|
109
|
+
}
|
|
110
|
+
}, [
|
|
111
|
+
disabled,
|
|
112
|
+
onMutationError,
|
|
113
|
+
onCommitted,
|
|
114
|
+
expectedRevision,
|
|
115
|
+
placeTreeNode,
|
|
116
|
+
busy,
|
|
117
|
+
parent,
|
|
118
|
+
placed,
|
|
119
|
+
collectionPath,
|
|
120
|
+
documentId,
|
|
121
|
+
t,
|
|
122
|
+
]);
|
|
123
|
+
const handlePick = useCallback((selection) => {
|
|
124
|
+
setPickerOpen(false);
|
|
125
|
+
const title = useAsTitle ? selection.record?.[useAsTitle] : undefined;
|
|
126
|
+
place(selection.targetDocumentId, {
|
|
127
|
+
id: selection.targetDocumentId,
|
|
128
|
+
title: typeof title === 'string' && title.length > 0 ? title : selection.targetDocumentId,
|
|
129
|
+
});
|
|
130
|
+
}, [place, useAsTitle]);
|
|
131
|
+
if (!treeServicesReady)
|
|
132
|
+
return null;
|
|
133
|
+
return (<div className={cx('byline-form-tree', styles.tree)}>
|
|
134
|
+
<span className={cx('byline-form-tree-heading', styles.heading)}>
|
|
135
|
+
{t('treeWidget.label')}
|
|
136
|
+
</span>
|
|
137
|
+
|
|
138
|
+
<div className={cx('byline-form-tree-current', styles.current)}>
|
|
139
|
+
<span className={styles.parentLabel}>{t('treeWidget.parentPrefix')}</span>{' '}
|
|
140
|
+
{!placed ? (<span className={cx(styles.parentValue, styles.root)}>{t('treeWidget.notInTree')}</span>) : parent ? (<span className={styles.parentValue}>{parent.title}</span>) : (<span className={cx(styles.parentValue, styles.root)}>{t('treeWidget.rootOption')}</span>)}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<div className={cx('byline-form-tree-actions', styles.actions)}>
|
|
144
|
+
<Button type="button" size="xs" variant="outlined" intent="noeffect" disabled={disabled || loading || busy} onClick={() => setPickerOpen(true)}>
|
|
145
|
+
{t('treeWidget.choose')}
|
|
146
|
+
</Button>
|
|
147
|
+
{!placed ? (<button type="button" className={cx('byline-form-tree-link', styles.link)} disabled={disabled || loading || busy} onClick={() => place(null, null)}>
|
|
148
|
+
{t('treeWidget.addToTree')}
|
|
149
|
+
</button>) : (parent != null && (<button type="button" className={cx('byline-form-tree-link', styles.link)} disabled={disabled || busy} onClick={() => place(null, null)}>
|
|
150
|
+
{t('treeWidget.makeRoot')}
|
|
151
|
+
</button>))}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{error != null && <p className={cx('byline-form-tree-error', styles.error)}>{error}</p>}
|
|
155
|
+
|
|
156
|
+
{targetDefinition != null && (<RelationPicker targetCollectionPath={collectionPath} targetDefinition={targetDefinition} displayField={useAsTitle} isOpen={pickerOpen} onSelect={handlePick} onDismiss={() => setPickerOpen(false)}/>)}
|
|
157
|
+
|
|
158
|
+
<span className={styles['sr-only']}>{t('treeWidget.srDescription')}</span>
|
|
159
|
+
</div>);
|
|
160
|
+
};
|
|
@@ -0,0 +1,368 @@
|
|
|
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
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { executeUploads } from './upload-executor';
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Fixtures
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/** Schema mirroring the publications files-array shape. */
|
|
14
|
+
const publicationsFields = [
|
|
15
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
16
|
+
{ name: 'serialNumber', label: 'Serial', type: 'counter', group: 'test-serial' },
|
|
17
|
+
{
|
|
18
|
+
name: 'files',
|
|
19
|
+
label: 'Files',
|
|
20
|
+
type: 'array',
|
|
21
|
+
fields: [
|
|
22
|
+
{
|
|
23
|
+
name: 'filesGroup',
|
|
24
|
+
type: 'group',
|
|
25
|
+
fields: [
|
|
26
|
+
{
|
|
27
|
+
name: 'publicationFile',
|
|
28
|
+
label: 'File',
|
|
29
|
+
type: 'file',
|
|
30
|
+
upload: {
|
|
31
|
+
mimeTypes: ['application/pdf'],
|
|
32
|
+
context: ['language', '/serialNumber', '../missingSibling'],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'language',
|
|
37
|
+
label: 'Language',
|
|
38
|
+
type: 'relation',
|
|
39
|
+
targetCollection: 'languages',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'cover',
|
|
47
|
+
label: 'Cover',
|
|
48
|
+
type: 'image',
|
|
49
|
+
upload: { mimeTypes: ['image/*'] },
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* Two block types that both declare an array named `gallery`, each holding a
|
|
54
|
+
* different upload-capable leaf. Leaf names stay unique across the
|
|
55
|
+
* collection, so this passes the boot-time uniqueness constraint — the shape
|
|
56
|
+
* is legal, and it is the shape that made block resolution ambiguous.
|
|
57
|
+
*/
|
|
58
|
+
const blocksFields = [
|
|
59
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
60
|
+
{
|
|
61
|
+
name: 'content',
|
|
62
|
+
label: 'Content',
|
|
63
|
+
type: 'blocks',
|
|
64
|
+
blocks: [
|
|
65
|
+
{
|
|
66
|
+
blockType: 'photoBlock',
|
|
67
|
+
fields: [
|
|
68
|
+
{
|
|
69
|
+
name: 'gallery',
|
|
70
|
+
label: 'Gallery',
|
|
71
|
+
type: 'array',
|
|
72
|
+
fields: [
|
|
73
|
+
{
|
|
74
|
+
name: 'heroImage',
|
|
75
|
+
label: 'Hero',
|
|
76
|
+
type: 'image',
|
|
77
|
+
upload: { context: ['/title'] },
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
blockType: 'videoBlock',
|
|
85
|
+
fields: [
|
|
86
|
+
{
|
|
87
|
+
name: 'gallery',
|
|
88
|
+
label: 'Gallery',
|
|
89
|
+
type: 'array',
|
|
90
|
+
fields: [
|
|
91
|
+
{
|
|
92
|
+
name: 'poster',
|
|
93
|
+
label: 'Poster',
|
|
94
|
+
type: 'image',
|
|
95
|
+
upload: { context: ['../caption', '/title'] },
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
{ name: 'caption', label: 'Caption', type: 'text' },
|
|
100
|
+
{
|
|
101
|
+
// Declared directly on the block, unlike `poster` above. This is
|
|
102
|
+
// the only depth at which `..` must leave the block entirely, so
|
|
103
|
+
// it is the only depth that can detect a scope miscount.
|
|
104
|
+
name: 'blockFile',
|
|
105
|
+
label: 'Block file',
|
|
106
|
+
type: 'file',
|
|
107
|
+
upload: { context: ['caption', '../title'] },
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
/** Form state for `blocksFields`: a photoBlock then a videoBlock. */
|
|
115
|
+
const blocksFormValues = () => ({
|
|
116
|
+
title: 'Hello',
|
|
117
|
+
content: [
|
|
118
|
+
{ _type: 'photoBlock', _id: 'blk-photo', gallery: [{ _id: 'gallery-photo' }] },
|
|
119
|
+
{
|
|
120
|
+
_type: 'videoBlock',
|
|
121
|
+
_id: 'blk-video',
|
|
122
|
+
gallery: [{ _id: 'gallery-video' }],
|
|
123
|
+
caption: 'Video caption',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
function imageUpload(name = 'p.png') {
|
|
128
|
+
return {
|
|
129
|
+
file: new File(['x'], name, { type: 'image/png' }),
|
|
130
|
+
previewUrl: 'blob:mock',
|
|
131
|
+
collectionPath: 'pages',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function pendingUpload(name = 'test.pdf') {
|
|
135
|
+
return {
|
|
136
|
+
file: new File(['%PDF'], name, { type: 'application/pdf' }),
|
|
137
|
+
previewUrl: 'blob:mock',
|
|
138
|
+
collectionPath: 'publications',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Capture the FormData each call receives; echo a minimal result. */
|
|
142
|
+
function captureUploadField() {
|
|
143
|
+
const bodies = [];
|
|
144
|
+
const fn = vi.fn(async (_collection, formData) => {
|
|
145
|
+
bodies.push(formData);
|
|
146
|
+
return { storedFile: { fileId: '1', filename: 'stored.pdf' } };
|
|
147
|
+
});
|
|
148
|
+
return { fn, bodies };
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Tests
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
describe('executeUploads — context transmission', () => {
|
|
154
|
+
it('always sends field (leaf name) and fieldPath; documentId only when provided', async () => {
|
|
155
|
+
const { fn, bodies } = captureUploadField();
|
|
156
|
+
const uploads = new Map([['files[0].filesGroup.publicationFile', pendingUpload()]]);
|
|
157
|
+
await executeUploads(uploads, fn, {
|
|
158
|
+
documentId: 'doc-123',
|
|
159
|
+
fields: publicationsFields,
|
|
160
|
+
getFormValues: () => ({}),
|
|
161
|
+
});
|
|
162
|
+
const body = bodies[0];
|
|
163
|
+
expect(body.get('field')).toBe('publicationFile');
|
|
164
|
+
expect(body.get('fieldPath')).toBe('files[0].filesGroup.publicationFile');
|
|
165
|
+
expect(body.get('documentId')).toBe('doc-123');
|
|
166
|
+
});
|
|
167
|
+
it('omits documentId in create mode (undefined)', async () => {
|
|
168
|
+
const { fn, bodies } = captureUploadField();
|
|
169
|
+
const uploads = new Map([['cover', pendingUpload('c.png')]]);
|
|
170
|
+
await executeUploads(uploads, fn, { fields: publicationsFields, getFormValues: () => ({}) });
|
|
171
|
+
expect(bodies[0]?.get('documentId')).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
it('resolves sibling, root-absolute, and missing context paths against the item scope', async () => {
|
|
174
|
+
const { fn, bodies } = captureUploadField();
|
|
175
|
+
const uploads = new Map([['files[1].filesGroup.publicationFile', pendingUpload()]]);
|
|
176
|
+
await executeUploads(uploads, fn, {
|
|
177
|
+
documentId: 'doc-123',
|
|
178
|
+
fields: publicationsFields,
|
|
179
|
+
getFormValues: () => ({
|
|
180
|
+
serialNumber: 447,
|
|
181
|
+
files: [
|
|
182
|
+
{ filesGroup: { language: { targetDocumentId: 'lang-th', targetCollectionId: 'c' } } },
|
|
183
|
+
{ filesGroup: { language: { targetDocumentId: 'lang-en', targetCollectionId: 'c' } } },
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
const body = bodies[0];
|
|
188
|
+
// Sibling path reads THIS item's language, not item 0's.
|
|
189
|
+
expect(body.get('language')).toBe('lang-en');
|
|
190
|
+
// Root-absolute path.
|
|
191
|
+
expect(body.get('serialNumber')).toBe('447');
|
|
192
|
+
// Unresolvable context values are omitted, not sent as ''.
|
|
193
|
+
expect(body.get('missingSibling')).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
it('serialises relation envelopes to their targetDocumentId', async () => {
|
|
196
|
+
const { fn, bodies } = captureUploadField();
|
|
197
|
+
const uploads = new Map([['files[0].filesGroup.publicationFile', pendingUpload()]]);
|
|
198
|
+
await executeUploads(uploads, fn, {
|
|
199
|
+
fields: publicationsFields,
|
|
200
|
+
getFormValues: () => ({
|
|
201
|
+
serialNumber: 1,
|
|
202
|
+
files: [
|
|
203
|
+
{ filesGroup: { language: { targetDocumentId: 'lang-de', targetCollectionId: 'c' } } },
|
|
204
|
+
],
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
expect(bodies[0]?.get('language')).toBe('lang-de');
|
|
208
|
+
});
|
|
209
|
+
it('fields without upload.context send no extra entries', async () => {
|
|
210
|
+
const { fn, bodies } = captureUploadField();
|
|
211
|
+
const uploads = new Map([['cover', pendingUpload('c.png')]]);
|
|
212
|
+
await executeUploads(uploads, fn, {
|
|
213
|
+
documentId: 'doc-9',
|
|
214
|
+
fields: publicationsFields,
|
|
215
|
+
getFormValues: () => ({ serialNumber: 5 }),
|
|
216
|
+
});
|
|
217
|
+
const body = bodies[0];
|
|
218
|
+
expect(body.get('field')).toBe('cover');
|
|
219
|
+
expect(body.get('serialNumber')).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
it('works without an execution context (legacy call shape)', async () => {
|
|
222
|
+
const { fn, bodies } = captureUploadField();
|
|
223
|
+
const uploads = new Map([['cover', pendingUpload('c.png')]]);
|
|
224
|
+
const result = await executeUploads(uploads, fn);
|
|
225
|
+
expect(result.allSucceeded).toBe(true);
|
|
226
|
+
const body = bodies[0];
|
|
227
|
+
expect(body.get('field')).toBe('cover');
|
|
228
|
+
expect(body.get('fieldPath')).toBe('cover');
|
|
229
|
+
expect(body.get('documentId')).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Block resolution
|
|
234
|
+
//
|
|
235
|
+
// A form field path is an INSTANCE path — `content[1].gallery[0].poster` — and
|
|
236
|
+
// carries no block type, because a block item holds its own `_type`. Resolving
|
|
237
|
+
// an upload field inside a block therefore means reading that item.
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
describe('executeUploads — upload fields inside blocks', () => {
|
|
240
|
+
it('resolves upload.context for a field in the first block', async () => {
|
|
241
|
+
const { fn, bodies } = captureUploadField();
|
|
242
|
+
const uploads = new Map([['content[0].gallery[0].heroImage', imageUpload('h.png')]]);
|
|
243
|
+
await executeUploads(uploads, fn, {
|
|
244
|
+
fields: blocksFields,
|
|
245
|
+
getFormValues: blocksFormValues,
|
|
246
|
+
});
|
|
247
|
+
expect(bodies[0]?.get('title')).toBe('Hello');
|
|
248
|
+
});
|
|
249
|
+
it('resolves upload.context for a field in a later block', async () => {
|
|
250
|
+
// Regression: block resolution used to match the first block declaring a
|
|
251
|
+
// field named by the next path segment. Both blocks declare `gallery`, so
|
|
252
|
+
// `photoBlock` won, `poster` was not found in it, and the declared context
|
|
253
|
+
// was dropped silently — no error, just a missing value the server-side
|
|
254
|
+
// beforeStore / afterStore hooks were relying on.
|
|
255
|
+
const { fn, bodies } = captureUploadField();
|
|
256
|
+
const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]]);
|
|
257
|
+
await executeUploads(uploads, fn, {
|
|
258
|
+
fields: blocksFields,
|
|
259
|
+
getFormValues: blocksFormValues,
|
|
260
|
+
});
|
|
261
|
+
expect(bodies[0]?.get('title')).toBe('Hello');
|
|
262
|
+
});
|
|
263
|
+
it('addresses a block item by stable id as well as position', async () => {
|
|
264
|
+
const { fn, bodies } = captureUploadField();
|
|
265
|
+
const uploads = new Map([['content[id=blk-video].gallery[0].poster', imageUpload()]]);
|
|
266
|
+
await executeUploads(uploads, fn, {
|
|
267
|
+
fields: blocksFields,
|
|
268
|
+
getFormValues: blocksFormValues,
|
|
269
|
+
});
|
|
270
|
+
expect(bodies[0]?.get('title')).toBe('Hello');
|
|
271
|
+
});
|
|
272
|
+
it('resolves sibling context through stable outer and inner item paths', async () => {
|
|
273
|
+
const { fn, bodies } = captureUploadField();
|
|
274
|
+
const uploads = new Map([
|
|
275
|
+
['content[id=blk-video].gallery[id=gallery-video].poster', imageUpload()],
|
|
276
|
+
]);
|
|
277
|
+
await executeUploads(uploads, fn, {
|
|
278
|
+
fields: blocksFields,
|
|
279
|
+
getFormValues: blocksFormValues,
|
|
280
|
+
});
|
|
281
|
+
expect(bodies[0]?.get('caption')).toBe('Video caption');
|
|
282
|
+
expect(bodies[0]?.get('title')).toBe('Hello');
|
|
283
|
+
});
|
|
284
|
+
it('climbs out of the block to the document root for an upload declared on the block', async () => {
|
|
285
|
+
// The scope boundary, and the one case that can catch a miscount.
|
|
286
|
+
//
|
|
287
|
+
// `poster` sits inside `gallery[]`, so its `../caption` climbs from the
|
|
288
|
+
// array item to the *block item* — a hop that stays inside the block.
|
|
289
|
+
// `blockFile` sits on the block itself, so `../title` has to leave the
|
|
290
|
+
// block and land at the document root.
|
|
291
|
+
//
|
|
292
|
+
// Those two behave differently under a path that carries a segment which
|
|
293
|
+
// addresses nothing. An abandoned experiment qualified instance paths with
|
|
294
|
+
// the block type (`content[1].videoBlock.blockFile`), and because
|
|
295
|
+
// `resolveContextPath` counts every dotted segment as one scope,`..`
|
|
296
|
+
// stopped one level short: `../title` resolved to `content[1].title`
|
|
297
|
+
// inside the block rather than the root, and arrived empty. The
|
|
298
|
+
// inside-the-block case absorbed the extra segment and kept passing —
|
|
299
|
+
// which is exactly why it cannot stand in for this one.
|
|
300
|
+
const { fn, bodies } = captureUploadField();
|
|
301
|
+
const uploads = new Map([['content[1].blockFile', pendingUpload()]]);
|
|
302
|
+
await executeUploads(uploads, fn, {
|
|
303
|
+
fields: blocksFields,
|
|
304
|
+
getFormValues: blocksFormValues,
|
|
305
|
+
});
|
|
306
|
+
// Root-level `title`, not the block's own `caption` scope.
|
|
307
|
+
expect(bodies[0]?.get('title')).toBe('Hello');
|
|
308
|
+
// The sibling still resolves, so a failure above is the climb specifically
|
|
309
|
+
// and not block resolution having gone wrong generally.
|
|
310
|
+
expect(bodies[0]?.get('caption')).toBe('Video caption');
|
|
311
|
+
});
|
|
312
|
+
it('still resolves when the addressed block item is missing from form state', async () => {
|
|
313
|
+
// Form state can lag a pending upload. The item is what supplies `_type`,
|
|
314
|
+
// so its absence drops us to the unique-match fallback — but the *field*
|
|
315
|
+
// is a declaration, and `poster` is declared in exactly one block, so the
|
|
316
|
+
// answer is unchanged. Staleness costs nothing here; genuine ambiguity
|
|
317
|
+
// would still return nothing (see the ambiguous case below).
|
|
318
|
+
const { fn, bodies } = captureUploadField();
|
|
319
|
+
const uploads = new Map([['content[7].gallery[0].poster', imageUpload()]]);
|
|
320
|
+
await executeUploads(uploads, fn, {
|
|
321
|
+
fields: blocksFields,
|
|
322
|
+
getFormValues: blocksFormValues,
|
|
323
|
+
});
|
|
324
|
+
expect(bodies[0]?.get('title')).toBe('Hello');
|
|
325
|
+
expect(bodies[0]?.get('fieldPath')).toBe('content[7].gallery[0].poster');
|
|
326
|
+
});
|
|
327
|
+
it('falls back to a unique match when form values cannot disambiguate', async () => {
|
|
328
|
+
// Without data the block type is unknowable from the path. `poster` is
|
|
329
|
+
// declared in exactly one block, so the answer is still unambiguous.
|
|
330
|
+
const { fn, bodies } = captureUploadField();
|
|
331
|
+
const uploads = new Map([['content[1].gallery[0].poster', imageUpload()]]);
|
|
332
|
+
await executeUploads(uploads, fn, {
|
|
333
|
+
fields: blocksFields,
|
|
334
|
+
getFormValues: () => ({ title: 'Hello' }),
|
|
335
|
+
});
|
|
336
|
+
expect(bodies[0]?.get('title')).toBe('Hello');
|
|
337
|
+
});
|
|
338
|
+
it('sends no context when the path genuinely identifies no single declaration', async () => {
|
|
339
|
+
// Same leaf name in both blocks and no data to choose between them. A
|
|
340
|
+
// guess would be wrong half the time, so nothing is sent.
|
|
341
|
+
const ambiguous = [
|
|
342
|
+
{ name: 'title', label: 'Title', type: 'text' },
|
|
343
|
+
{
|
|
344
|
+
name: 'content',
|
|
345
|
+
label: 'Content',
|
|
346
|
+
type: 'blocks',
|
|
347
|
+
blocks: [
|
|
348
|
+
{
|
|
349
|
+
blockType: 'aBlock',
|
|
350
|
+
fields: [
|
|
351
|
+
{ name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
|
|
352
|
+
],
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
blockType: 'bBlock',
|
|
356
|
+
fields: [
|
|
357
|
+
{ name: 'shared', label: 'S', type: 'image', upload: { context: ['/title'] } },
|
|
358
|
+
],
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
},
|
|
362
|
+
];
|
|
363
|
+
const { fn, bodies } = captureUploadField();
|
|
364
|
+
const uploads = new Map([['content[1].shared', imageUpload()]]);
|
|
365
|
+
await executeUploads(uploads, fn, { fields: ambiguous, getFormValues: () => ({ title: 'x' }) });
|
|
366
|
+
expect(bodies[0]?.get('title')).toBeNull();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { buildFieldToTabPath } from './use-form-layout';
|
|
10
|
+
const field = (name) => ({ name, type: 'text', label: name });
|
|
11
|
+
const fieldNames = ['title', 'body', 'seoTitle', 'seoDesc', 'note'];
|
|
12
|
+
const fieldByName = new Map(fieldNames.map((n) => [n, field(n)]));
|
|
13
|
+
const rowByName = new Map([
|
|
14
|
+
['seoRow', { name: 'seoRow', fields: ['seoTitle', 'seoDesc'] }],
|
|
15
|
+
]);
|
|
16
|
+
const groupByName = new Map([
|
|
17
|
+
['seoGroup', { name: 'seoGroup', label: 'SEO', fields: ['seoRow'] }],
|
|
18
|
+
]);
|
|
19
|
+
describe('buildFieldToTabPath', () => {
|
|
20
|
+
it('returns an empty map when there are no tab sets', () => {
|
|
21
|
+
const map = buildFieldToTabPath(undefined, fieldByName, rowByName, groupByName);
|
|
22
|
+
expect(map.size).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
it('maps direct fields to their tab set + tab', () => {
|
|
25
|
+
const adminConfig = {
|
|
26
|
+
tabSets: [
|
|
27
|
+
{
|
|
28
|
+
name: 'main',
|
|
29
|
+
tabs: [
|
|
30
|
+
{ name: 'content', label: 'Content', fields: ['title', 'body'] },
|
|
31
|
+
{ name: 'meta', label: 'Meta', fields: ['note'] },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
const map = buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName);
|
|
37
|
+
expect(map.get('title')).toEqual({ tabSetName: 'main', tabName: 'content' });
|
|
38
|
+
expect(map.get('body')).toEqual({ tabSetName: 'main', tabName: 'content' });
|
|
39
|
+
expect(map.get('note')).toEqual({ tabSetName: 'main', tabName: 'meta' });
|
|
40
|
+
});
|
|
41
|
+
it('recurses through rows and groups to reach nested fields', () => {
|
|
42
|
+
const adminConfig = {
|
|
43
|
+
tabSets: [
|
|
44
|
+
{
|
|
45
|
+
name: 'main',
|
|
46
|
+
tabs: [{ name: 'seo', label: 'SEO', fields: ['seoGroup'] }],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const map = buildFieldToTabPath(adminConfig, fieldByName, rowByName, groupByName);
|
|
51
|
+
// seoGroup → seoRow → [seoTitle, seoDesc]
|
|
52
|
+
expect(map.get('seoTitle')).toEqual({ tabSetName: 'main', tabName: 'seo' });
|
|
53
|
+
expect(map.get('seoDesc')).toEqual({ tabSetName: 'main', tabName: 'seo' });
|
|
54
|
+
// Container names themselves are not fields, so are not indexed.
|
|
55
|
+
expect(map.has('seoGroup')).toBe(false);
|
|
56
|
+
expect(map.has('seoRow')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('does not loop forever on a self-referential row cycle', () => {
|
|
59
|
+
const cyclicRows = new Map([
|
|
60
|
+
['loop', { name: 'loop', fields: ['loop', 'title'] }],
|
|
61
|
+
]);
|
|
62
|
+
const adminConfig = {
|
|
63
|
+
tabSets: [{ name: 'main', tabs: [{ name: 't', label: 'T', fields: ['loop'] }] }],
|
|
64
|
+
};
|
|
65
|
+
const map = buildFieldToTabPath(adminConfig, fieldByName, cyclicRows, groupByName);
|
|
66
|
+
// The cycle guard stops the re-visit; the real field is still indexed.
|
|
67
|
+
expect(map.get('title')).toEqual({ tabSetName: 'main', tabName: 't' });
|
|
68
|
+
});
|
|
69
|
+
});
|