@byline/admin 3.15.1 → 3.16.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/relation/relation-many-field.js +19 -11
- package/dist/fields/relation/relation-picker.d.ts +40 -17
- package/dist/fields/relation/relation-picker.js +84 -28
- package/dist/fields/relation/relation-picker.module.js +9 -0
- package/dist/fields/relation/relation-picker_module.css +41 -0
- package/dist/forms/form-context.d.ts +2 -2
- package/dist/forms/form-modals.d.ts +1 -1
- package/dist/forms/form-renderer.d.ts +2 -2
- package/dist/forms/tree-placement-widget.d.ts +1 -1
- package/dist/lib/create-command.d.ts +1 -1
- package/dist/modules/admin-activity/abilities.d.ts +1 -1
- package/dist/modules/admin-activity/index.d.ts +1 -1
- package/dist/modules/admin-permissions/abilities.d.ts +1 -1
- package/dist/modules/admin-users/repository.d.ts +1 -1
- package/dist/widgets/source-locale-badge/source-locale-badge.d.ts +1 -1
- package/package.json +5 -6
- package/src/fields/field-services-types.ts +1 -1
- package/src/fields/relation/relation-many-field.tsx +22 -15
- package/src/fields/relation/relation-picker.module.css +49 -0
- package/src/fields/relation/relation-picker.tsx +130 -23
- package/src/forms/available-locales-reconcile.test.node.ts +1 -1
- package/src/forms/form-context.tsx +3 -3
- package/src/forms/form-modals.tsx +1 -1
- package/src/forms/form-renderer.tsx +4 -4
- package/src/forms/tree-placement-widget.tsx +1 -1
- package/src/lib/create-command.ts +1 -1
- package/src/modules/admin-activity/abilities.ts +1 -1
- package/src/modules/admin-activity/index.ts +1 -1
- package/src/modules/admin-permissions/abilities.ts +1 -1
- package/src/modules/admin-permissions/components/inspector.tsx +1 -1
- package/src/modules/admin-users/repository.ts +1 -1
- package/src/widgets/source-locale-badge/source-locale-badge.tsx +1 -1
|
@@ -74,20 +74,26 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
|
|
|
74
74
|
if (Array.isArray(v)) return v;
|
|
75
75
|
return Array.isArray(defaultValue) ? defaultValue : [];
|
|
76
76
|
};
|
|
77
|
-
const
|
|
77
|
+
const handleAddMany = (selections)=>{
|
|
78
78
|
setPickerOpen(false);
|
|
79
79
|
const current = currentArray();
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
const existing = new Set(current.map((v)=>v.targetDocumentId));
|
|
81
|
+
const additions = selections.filter((s)=>!existing.has(s.targetDocumentId));
|
|
82
|
+
if (0 === additions.length) return;
|
|
83
|
+
const records = additions.filter((s)=>null != s.record);
|
|
84
|
+
if (records.length > 0) setPickedRecords((prev)=>{
|
|
85
|
+
const next = {
|
|
86
|
+
...prev
|
|
87
|
+
};
|
|
88
|
+
for (const s of records)next[s.targetDocumentId] = s.record;
|
|
89
|
+
return next;
|
|
90
|
+
});
|
|
85
91
|
setFieldValue(fieldPath, [
|
|
86
92
|
...current,
|
|
87
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
...additions.map((s)=>({
|
|
94
|
+
targetDocumentId: s.targetDocumentId,
|
|
95
|
+
targetCollectionId: s.targetCollectionId
|
|
96
|
+
}))
|
|
91
97
|
]);
|
|
92
98
|
};
|
|
93
99
|
const handleRemove = (targetDocumentId)=>{
|
|
@@ -178,11 +184,13 @@ const RelationManyField = ({ field, defaultValue, id, path })=>{
|
|
|
178
184
|
})
|
|
179
185
|
}),
|
|
180
186
|
/*#__PURE__*/ jsx(RelationPicker, {
|
|
187
|
+
multiple: true,
|
|
181
188
|
targetCollectionPath: field.targetCollection,
|
|
182
189
|
targetDefinition: targetDef,
|
|
183
190
|
displayField: field.displayField,
|
|
184
191
|
isOpen: pickerOpen,
|
|
185
|
-
|
|
192
|
+
excludeIds: items.map((v)=>v.targetDocumentId),
|
|
193
|
+
onSelectMany: handleAddMany,
|
|
186
194
|
onDismiss: ()=>setPickerOpen(false)
|
|
187
195
|
})
|
|
188
196
|
]
|
|
@@ -18,7 +18,20 @@ import type { CollectionDefinition } from '@byline/core';
|
|
|
18
18
|
*
|
|
19
19
|
* Paths 2–4 render a single-line label (primary) + `path` (secondary).
|
|
20
20
|
*/
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* One confirmed pick. `record` is the raw document the picker row rendered —
|
|
23
|
+
* the caller can use it to show the selected value in its own tile without a
|
|
24
|
+
* refetch. The fields available on `record` are whatever `resolveSelectFields`
|
|
25
|
+
* asked the listing endpoint for (picker columns + `useAsTitle` +
|
|
26
|
+
* `displayField`), so any display surface downstream of the picker that also
|
|
27
|
+
* renders from those same columns will find the data it needs.
|
|
28
|
+
*/
|
|
29
|
+
export interface RelationPickerSelection {
|
|
30
|
+
targetDocumentId: string;
|
|
31
|
+
targetCollectionId: string;
|
|
32
|
+
record?: Record<string, any>;
|
|
33
|
+
}
|
|
34
|
+
interface RelationPickerBaseProps {
|
|
22
35
|
/** The target collection path (e.g. `'media'`). */
|
|
23
36
|
targetCollectionPath: string;
|
|
24
37
|
/** The target collection definition (used for labels + displayField fallback). */
|
|
@@ -37,23 +50,33 @@ interface RelationPickerProps {
|
|
|
37
50
|
extraSelectFields?: string[];
|
|
38
51
|
/** Modal open/close state. */
|
|
39
52
|
isOpen: boolean;
|
|
40
|
-
/**
|
|
41
|
-
* Called with the picked selection when the user confirms.
|
|
42
|
-
*
|
|
43
|
-
* `record` is the raw document the picker row rendered — the caller can
|
|
44
|
-
* use it to show the selected value in its own tile without a refetch.
|
|
45
|
-
* The fields available on `record` are whatever `resolveSelectFields`
|
|
46
|
-
* asked the listing endpoint for (picker columns + `useAsTitle` +
|
|
47
|
-
* `displayField`), so any display surface downstream of the picker that
|
|
48
|
-
* also renders from those same columns will find the data it needs.
|
|
49
|
-
*/
|
|
50
|
-
onSelect: (selection: {
|
|
51
|
-
targetDocumentId: string;
|
|
52
|
-
targetCollectionId: string;
|
|
53
|
-
record?: Record<string, any>;
|
|
54
|
-
}) => void;
|
|
55
53
|
/** Called when the user dismisses the modal. */
|
|
56
54
|
onDismiss: () => void;
|
|
57
55
|
}
|
|
58
|
-
|
|
56
|
+
interface RelationPickerSingleProps extends RelationPickerBaseProps {
|
|
57
|
+
/** Single-select (default): clicking a row selects it; confirm returns one pick. */
|
|
58
|
+
multiple?: false;
|
|
59
|
+
/** Called with the picked selection when the user confirms. */
|
|
60
|
+
onSelect: (selection: RelationPickerSelection) => void;
|
|
61
|
+
onSelectMany?: never;
|
|
62
|
+
excludeIds?: never;
|
|
63
|
+
}
|
|
64
|
+
interface RelationPickerMultiProps extends RelationPickerBaseProps {
|
|
65
|
+
/**
|
|
66
|
+
* Multi-select mode (`hasMany` widgets): rows toggle a check state and the
|
|
67
|
+
* confirm action returns every selection in pick order — several picks in
|
|
68
|
+
* one trip instead of reopening the modal per item.
|
|
69
|
+
*/
|
|
70
|
+
multiple: true;
|
|
71
|
+
/** Called with the full selection set (pick order) when the user confirms. */
|
|
72
|
+
onSelectMany: (selections: RelationPickerSelection[]) => void;
|
|
73
|
+
onSelect?: never;
|
|
74
|
+
/**
|
|
75
|
+
* Target ids already present on the caller's value. Rendered as disabled
|
|
76
|
+
* "already added" rows so the same target can't be picked twice.
|
|
77
|
+
*/
|
|
78
|
+
excludeIds?: string[];
|
|
79
|
+
}
|
|
80
|
+
type RelationPickerProps = RelationPickerSingleProps | RelationPickerMultiProps;
|
|
81
|
+
export declare const RelationPicker: ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, multiple, excludeIds, onSelect, onSelectMany, onDismiss, }: RelationPickerProps) => import("react").JSX.Element;
|
|
59
82
|
export {};
|
|
@@ -2,17 +2,18 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useCallback, useEffect, useState } from "react";
|
|
3
3
|
import { getCollectionAdminConfig, resolveItemViewColumns } from "@byline/core";
|
|
4
4
|
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
-
import { Button, LoaderRing, Modal, Search } from "@byline/ui/react";
|
|
5
|
+
import { Button, CheckIcon, LoaderRing, Modal, Search } from "@byline/ui/react";
|
|
6
6
|
import classnames from "classnames";
|
|
7
7
|
import { useBylineFieldServices } from "../field-services-context.js";
|
|
8
8
|
import { PickerCell, resolveFallbackDisplayField, resolveRowLabel, resolveSelectFields } from "./relation-display.js";
|
|
9
9
|
import relation_picker_module from "./relation-picker.module.js";
|
|
10
10
|
const PAGE_SIZE = 15;
|
|
11
|
-
const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, onSelect, onDismiss })=>{
|
|
11
|
+
const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, multiple = false, excludeIds, onSelect, onSelectMany, onDismiss })=>{
|
|
12
12
|
const [query, setQuery] = useState('');
|
|
13
13
|
const [page, setPage] = useState(1);
|
|
14
14
|
const { t } = useTranslation('byline-admin');
|
|
15
15
|
const [selectedDocumentId, setSelectedDocumentId] = useState(null);
|
|
16
|
+
const [selectedMap, setSelectedMap] = useState(()=>new Map());
|
|
16
17
|
const [loading, setLoading] = useState(false);
|
|
17
18
|
const [error, setError] = useState(null);
|
|
18
19
|
const [documents, setDocuments] = useState([]);
|
|
@@ -26,6 +27,7 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
|
|
|
26
27
|
setQuery('');
|
|
27
28
|
setPage(1);
|
|
28
29
|
setSelectedDocumentId(null);
|
|
30
|
+
setSelectedMap(new Map());
|
|
29
31
|
setError(null);
|
|
30
32
|
}
|
|
31
33
|
}, [
|
|
@@ -73,7 +75,7 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
|
|
|
73
75
|
]);
|
|
74
76
|
const resolvedDisplayField = displayField ?? targetDefinition?.useAsTitle ?? resolveFallbackDisplayField(targetDefinition) ?? null;
|
|
75
77
|
const handleSelect = useCallback(()=>{
|
|
76
|
-
if (!selectedDocumentId || !collectionId) return;
|
|
78
|
+
if (!selectedDocumentId || !collectionId || !onSelect) return;
|
|
77
79
|
const record = documents.find((d)=>d?.id === selectedDocumentId);
|
|
78
80
|
onSelect({
|
|
79
81
|
targetDocumentId: selectedDocumentId,
|
|
@@ -86,6 +88,27 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
|
|
|
86
88
|
documents,
|
|
87
89
|
onSelect
|
|
88
90
|
]);
|
|
91
|
+
const handleSelectMany = useCallback(()=>{
|
|
92
|
+
if (0 === selectedMap.size || !collectionId || !onSelectMany) return;
|
|
93
|
+
onSelectMany(Array.from(selectedMap, ([targetDocumentId, record])=>({
|
|
94
|
+
targetDocumentId,
|
|
95
|
+
targetCollectionId: collectionId,
|
|
96
|
+
record
|
|
97
|
+
})));
|
|
98
|
+
}, [
|
|
99
|
+
selectedMap,
|
|
100
|
+
collectionId,
|
|
101
|
+
onSelectMany
|
|
102
|
+
]);
|
|
103
|
+
const toggleSelected = useCallback((doc)=>{
|
|
104
|
+
const id = doc.id;
|
|
105
|
+
setSelectedMap((prev)=>{
|
|
106
|
+
const next = new Map(prev);
|
|
107
|
+
if (next.has(id)) next.delete(id);
|
|
108
|
+
else next.set(id, doc);
|
|
109
|
+
return next;
|
|
110
|
+
});
|
|
111
|
+
}, []);
|
|
89
112
|
const title = t('fields.relation.selectPickerTitle', {
|
|
90
113
|
label: targetDefinition?.labels.singular ?? targetCollectionPath
|
|
91
114
|
});
|
|
@@ -143,34 +166,65 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
|
|
|
143
166
|
className: classnames('byline-field-relation-picker-rows', relation_picker_module.rows),
|
|
144
167
|
children: documents.map((doc)=>{
|
|
145
168
|
const id = doc.id;
|
|
146
|
-
const selected = selectedDocumentId === id;
|
|
169
|
+
const selected = multiple ? selectedMap.has(id) : selectedDocumentId === id;
|
|
170
|
+
const excluded = multiple && (excludeIds?.includes(id) ?? false);
|
|
147
171
|
return /*#__PURE__*/ jsx("li", {
|
|
148
|
-
children: /*#__PURE__*/
|
|
172
|
+
children: /*#__PURE__*/ jsxs("button", {
|
|
149
173
|
type: "button",
|
|
150
|
-
|
|
174
|
+
disabled: excluded,
|
|
175
|
+
"aria-pressed": multiple ? selected : void 0,
|
|
176
|
+
title: excluded ? t('fields.relation.picker.alreadyAdded') : void 0,
|
|
177
|
+
className: classnames('byline-field-relation-picker-row-button', relation_picker_module["row-button"], multiple && [
|
|
178
|
+
'byline-field-relation-picker-row-multi',
|
|
179
|
+
relation_picker_module["row-multi"]
|
|
180
|
+
], selected && [
|
|
151
181
|
'byline-field-relation-picker-row-selected',
|
|
152
182
|
relation_picker_module["row-selected"]
|
|
183
|
+
], excluded && [
|
|
184
|
+
'byline-field-relation-picker-row-added',
|
|
185
|
+
relation_picker_module["row-added"]
|
|
153
186
|
]),
|
|
154
|
-
onClick: ()=>
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
className: classnames('byline-field-relation-picker-row-path', relation_picker_module["row-path"]),
|
|
170
|
-
children: doc.path
|
|
187
|
+
onClick: ()=>{
|
|
188
|
+
if (excluded) return;
|
|
189
|
+
if (multiple) toggleSelected(doc);
|
|
190
|
+
else setSelectedDocumentId(id);
|
|
191
|
+
},
|
|
192
|
+
children: [
|
|
193
|
+
multiple && /*#__PURE__*/ jsx("span", {
|
|
194
|
+
"aria-hidden": "true",
|
|
195
|
+
className: classnames('byline-field-relation-picker-check', relation_picker_module.check, (selected || excluded) && [
|
|
196
|
+
'byline-field-relation-picker-check-checked',
|
|
197
|
+
relation_picker_module["check-checked"]
|
|
198
|
+
]),
|
|
199
|
+
children: (selected || excluded) && /*#__PURE__*/ jsx(CheckIcon, {
|
|
200
|
+
width: "12px",
|
|
201
|
+
height: "12px"
|
|
171
202
|
})
|
|
172
|
-
|
|
173
|
-
|
|
203
|
+
}),
|
|
204
|
+
pickerColumns && pickerColumns.length > 0 ? /*#__PURE__*/ jsx("div", {
|
|
205
|
+
className: classnames('byline-field-relation-picker-row-cells', relation_picker_module["row-cells"]),
|
|
206
|
+
children: pickerColumns.map((col)=>/*#__PURE__*/ jsx(PickerCell, {
|
|
207
|
+
column: col,
|
|
208
|
+
record: doc
|
|
209
|
+
}, String(col.fieldName)))
|
|
210
|
+
}) : /*#__PURE__*/ jsxs("div", {
|
|
211
|
+
className: classnames('byline-field-relation-picker-row-stack', relation_picker_module["row-stack"]),
|
|
212
|
+
children: [
|
|
213
|
+
/*#__PURE__*/ jsx("span", {
|
|
214
|
+
className: classnames('byline-field-relation-picker-row-label', relation_picker_module["row-label"]),
|
|
215
|
+
children: resolveRowLabel(doc, resolvedDisplayField) || id
|
|
216
|
+
}),
|
|
217
|
+
'string' == typeof doc.path && doc.path.length > 0 && /*#__PURE__*/ jsx("span", {
|
|
218
|
+
className: classnames('byline-field-relation-picker-row-path', relation_picker_module["row-path"]),
|
|
219
|
+
children: doc.path
|
|
220
|
+
})
|
|
221
|
+
]
|
|
222
|
+
}),
|
|
223
|
+
excluded && /*#__PURE__*/ jsx("span", {
|
|
224
|
+
className: classnames('byline-field-relation-picker-added-hint', relation_picker_module["added-hint"]),
|
|
225
|
+
children: t('fields.relation.picker.alreadyAdded')
|
|
226
|
+
})
|
|
227
|
+
]
|
|
174
228
|
})
|
|
175
229
|
}, id);
|
|
176
230
|
})
|
|
@@ -224,9 +278,11 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
|
|
|
224
278
|
className: classnames('byline-field-relation-picker-action', relation_picker_module.action),
|
|
225
279
|
intent: "primary",
|
|
226
280
|
type: "button",
|
|
227
|
-
disabled: !selectedDocumentId,
|
|
228
|
-
onClick: handleSelect,
|
|
229
|
-
children: t('
|
|
281
|
+
disabled: multiple ? 0 === selectedMap.size : !selectedDocumentId,
|
|
282
|
+
onClick: multiple ? handleSelectMany : handleSelect,
|
|
283
|
+
children: multiple ? t('fields.relation.picker.addSelected', {
|
|
284
|
+
count: selectedMap.size
|
|
285
|
+
}) : t('common.actions.select')
|
|
230
286
|
})
|
|
231
287
|
]
|
|
232
288
|
})
|
|
@@ -12,6 +12,15 @@ const relation_picker_module = {
|
|
|
12
12
|
rowButton: "row-button-ZHfXmj",
|
|
13
13
|
"row-selected": "row-selected-VlrgyO",
|
|
14
14
|
rowSelected: "row-selected-VlrgyO",
|
|
15
|
+
"row-multi": "row-multi-o06YCW",
|
|
16
|
+
rowMulti: "row-multi-o06YCW",
|
|
17
|
+
check: "check-gYmVOq",
|
|
18
|
+
"check-checked": "check-checked-Nopylj",
|
|
19
|
+
checkChecked: "check-checked-Nopylj",
|
|
20
|
+
"row-added": "row-added-PmlIQL",
|
|
21
|
+
rowAdded: "row-added-PmlIQL",
|
|
22
|
+
"added-hint": "added-hint-QGoYN3",
|
|
23
|
+
addedHint: "added-hint-QGoYN3",
|
|
15
24
|
"row-cells": "row-cells-aSIcf5",
|
|
16
25
|
rowCells: "row-cells-aSIcf5",
|
|
17
26
|
"row-stack": "row-stack-BOPzzj",
|
|
@@ -73,6 +73,47 @@
|
|
|
73
73
|
border-left: 2px solid var(--primary-200);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
:is(.row-multi-o06YCW, .byline-field-relation-picker-row-multi) {
|
|
77
|
+
align-items: center;
|
|
78
|
+
gap: .625rem;
|
|
79
|
+
display: flex;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
:is(.check-gYmVOq, .byline-field-relation-picker-check) {
|
|
83
|
+
border: 1px solid var(--gray-400);
|
|
84
|
+
color: #0000;
|
|
85
|
+
border-radius: 4px;
|
|
86
|
+
flex: none;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
align-items: center;
|
|
89
|
+
width: 16px;
|
|
90
|
+
height: 16px;
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
:is(.check-checked-Nopylj, .byline-field-relation-picker-check-checked) {
|
|
95
|
+
background-color: var(--primary-200);
|
|
96
|
+
border-color: var(--primary-200);
|
|
97
|
+
color: #fff;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
:is(.row-added-PmlIQL, .byline-field-relation-picker-row-added) {
|
|
101
|
+
opacity: .55;
|
|
102
|
+
cursor: default;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
:is(.row-added-PmlIQL:hover, .byline-field-relation-picker-row-added:hover) {
|
|
106
|
+
background-color: #0000;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
:is(.added-hint-QGoYN3, .byline-field-relation-picker-added-hint) {
|
|
110
|
+
color: var(--gray-500);
|
|
111
|
+
font-size: var(--font-size-xs);
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
flex: none;
|
|
114
|
+
margin-left: auto;
|
|
115
|
+
}
|
|
116
|
+
|
|
76
117
|
:is(.row-cells-aSIcf5, .byline-field-relation-picker-row-cells) {
|
|
77
118
|
align-items: center;
|
|
78
119
|
gap: .75rem;
|
|
@@ -35,7 +35,7 @@ type FieldUploadingListener = (uploading: boolean) => void;
|
|
|
35
35
|
* Save button. `content` mints a new version (normal workflow). `direct-write`
|
|
36
36
|
* is an immediate, non-versioned write of the document-grain system fields
|
|
37
37
|
* (path / advertised locales) that does NOT reset workflow status. `both` does
|
|
38
|
-
* each through its own write path. See docs/
|
|
38
|
+
* each through its own write path. See docs/07-internationalization/index.md.
|
|
39
39
|
*/
|
|
40
40
|
export type DirtyReason = 'none' | 'content' | 'direct-write' | 'both';
|
|
41
41
|
export interface DirtyBreakdown {
|
|
@@ -68,7 +68,7 @@ interface FormContextType {
|
|
|
68
68
|
/**
|
|
69
69
|
* Partition the current dirty state into content vs. system-field (path /
|
|
70
70
|
* advertised-locales) writes so the Save button can branch. See
|
|
71
|
-
* docs/
|
|
71
|
+
* docs/07-internationalization/index.md.
|
|
72
72
|
*/
|
|
73
73
|
getDirtyBreakdown: () => DirtyBreakdown;
|
|
74
74
|
subscribeField: (name: string, listener: FieldListener) => () => void;
|
|
@@ -10,7 +10,7 @@ export declare const UnsavedChangesModal: ({ onClose }: {
|
|
|
10
10
|
* Confirms an immediate, non-versioned write of the document-grain system
|
|
11
11
|
* fields (path / advertised locales) — which does NOT reset workflow status.
|
|
12
12
|
* When content is also dirty, the copy reassures that content edits still
|
|
13
|
-
* follow the normal revision + publish workflow. See docs/
|
|
13
|
+
* follow the normal revision + publish workflow. See docs/07-internationalization/index.md.
|
|
14
14
|
*/
|
|
15
15
|
export declare const SystemFieldsConfirmModal: ({ contentDirty, pathDirty, availableLocalesDirty, onCancel, onConfirm, }: {
|
|
16
16
|
contentDirty: boolean;
|
|
@@ -23,7 +23,7 @@ export interface PublishedVersionInfo {
|
|
|
23
23
|
* patches) alongside the document-grain system fields (path / advertised
|
|
24
24
|
* locales) and per-bucket dirty flags so the host can route each piece to the
|
|
25
25
|
* right write path — versioned for content, immediate/non-versioned for the
|
|
26
|
-
* system fields. See docs/
|
|
26
|
+
* system fields. See docs/07-internationalization/index.md.
|
|
27
27
|
*/
|
|
28
28
|
export interface SystemFieldsSubmitPayload {
|
|
29
29
|
data: any;
|
|
@@ -104,7 +104,7 @@ export interface FormRendererProps {
|
|
|
104
104
|
* Opts the document-tree placement widget into the sidebar (above the
|
|
105
105
|
* available-locales widget). Sourced from `CollectionDefinition.tree` by the
|
|
106
106
|
* caller. Renders only in edit mode (placement needs a persisted document)
|
|
107
|
-
* and only when the host wires the tree services. See docs/
|
|
107
|
+
* and only when the host wires the tree services. See docs/04-collections/03-document-trees.md.
|
|
108
108
|
*/
|
|
109
109
|
tree?: boolean;
|
|
110
110
|
headingLabel?: string;
|
|
@@ -8,7 +8,7 @@ export interface TreePlacementWidgetProps {
|
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* Sidebar widget for placing the current document within its collection's
|
|
11
|
-
* single-parent document tree (the `tree: true` primitive — docs/
|
|
11
|
+
* single-parent document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md).
|
|
12
12
|
*
|
|
13
13
|
* The tree is document-grain and **unversioned**, so changes here write
|
|
14
14
|
* immediately (independent of the form's content save). The editor picks a
|
|
@@ -12,7 +12,7 @@ import type { ZodType } from 'zod';
|
|
|
12
12
|
* contract (validate → authorise → invoke → shape) into a single
|
|
13
13
|
* declaration.
|
|
14
14
|
*
|
|
15
|
-
* Implements Phase 1 of `docs/
|
|
15
|
+
* Implements Phase 1 of `docs/03-architecture/02-core-composition.md`. Today's scope is
|
|
16
16
|
* `@byline/admin`-internal: it gates against admin actor identity using
|
|
17
17
|
* the existing `assertAdminActor` / `requireAdminActor` helpers, which
|
|
18
18
|
* inherit the super-admin bypass from `AdminAuth.assertAbility`.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { AbilityRegistry } from '@byline/auth';
|
|
9
9
|
/**
|
|
10
|
-
* Ability keys for the admin-activity module (docs/
|
|
10
|
+
* Ability keys for the admin-activity module (docs/06-auth-and-security/02-auditability.md — Workstream 4).
|
|
11
11
|
*
|
|
12
12
|
* `read` gates the system-wide activity area — the `/admin/activity` report
|
|
13
13
|
* over the version-stream + audit-log union. It is deliberately a **separate**
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
9
|
* `@byline/admin/admin-activity` — the system-wide activity area
|
|
10
|
-
* (docs/
|
|
10
|
+
* (docs/06-auth-and-security/02-auditability.md — Workstream 4).
|
|
11
11
|
*
|
|
12
12
|
* Unlike the other admin modules this one owns no table and no AdminStore
|
|
13
13
|
* repository: the activity feed is a read over the document db adapter's
|
|
@@ -9,7 +9,7 @@ import type { AbilityRegistry } from '@byline/auth';
|
|
|
9
9
|
/**
|
|
10
10
|
* Ability keys for the admin-permissions module.
|
|
11
11
|
*
|
|
12
|
-
* `read` gates the inspector view (see docs/
|
|
12
|
+
* `read` gates the inspector view (see docs/06-auth-and-security/01-authn-authz.md).
|
|
13
13
|
* `update` will gate the per-role ability editor mounted on the
|
|
14
14
|
* admin-roles role detail page — declared here so the role editor can
|
|
15
15
|
* assert against it once that surface lands. The per-role editor shares
|
|
@@ -113,7 +113,7 @@ export interface AdminUsersRepository {
|
|
|
113
113
|
getById(id: string): Promise<AdminUserRow | null>;
|
|
114
114
|
/**
|
|
115
115
|
* Bulk lookup for audit actor-label resolution (the `actors`
|
|
116
|
-
* map on admin document reads — see docs/
|
|
116
|
+
* map on admin document reads — see docs/06-auth-and-security/02-auditability.md, Workstream 1). Ids
|
|
117
117
|
* with no matching row are simply absent from the result; callers
|
|
118
118
|
* render a tombstone label for them.
|
|
119
119
|
*/
|
|
@@ -20,7 +20,7 @@ export interface SourceLocaleBadgeProps {
|
|
|
20
20
|
* NOTE: currently rendered for *every* document so the anchor is visible during
|
|
21
21
|
* development. The intended end state is to show it only when `locale` differs
|
|
22
22
|
* from the system's current default content locale (a normal single-default
|
|
23
|
-
* install then shows nothing). See docs/
|
|
23
|
+
* install then shows nothing). See docs/07-internationalization/index.md.
|
|
24
24
|
*
|
|
25
25
|
* Stable override handle: `.byline-source-locale-badge`.
|
|
26
26
|
*/
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.16.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -155,11 +155,10 @@
|
|
|
155
155
|
"react-diff-viewer-continued": "^4.2.2",
|
|
156
156
|
"uuid": "^14.0.0",
|
|
157
157
|
"zod": "^4.4.3",
|
|
158
|
-
"
|
|
159
|
-
"@byline/
|
|
160
|
-
"@byline/
|
|
161
|
-
"@byline/
|
|
162
|
-
"@byline/ui": "3.15.1"
|
|
158
|
+
"@byline/auth": "3.16.0",
|
|
159
|
+
"@byline/ui": "3.16.0",
|
|
160
|
+
"@byline/core": "3.16.0",
|
|
161
|
+
"@byline/i18n": "3.16.0"
|
|
163
162
|
},
|
|
164
163
|
"peerDependencies": {
|
|
165
164
|
"react": "^19.0.0",
|
|
@@ -62,7 +62,7 @@ export type UploadFieldFn = (
|
|
|
62
62
|
createDocument?: boolean
|
|
63
63
|
) => Promise<UploadedFileResult>
|
|
64
64
|
|
|
65
|
-
// --- Document tree (the `tree: true` primitive — docs/
|
|
65
|
+
// --- Document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md) -----
|
|
66
66
|
|
|
67
67
|
/** One hydrated ancestor in a document's breadcrumb trail (root-first). */
|
|
68
68
|
export interface TreeAncestor {
|
|
@@ -32,7 +32,7 @@ import cx from 'classnames'
|
|
|
32
32
|
|
|
33
33
|
import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context'
|
|
34
34
|
import styles from './relation-field.module.css'
|
|
35
|
-
import { RelationPicker } from './relation-picker'
|
|
35
|
+
import { RelationPicker, type RelationPickerSelection } from './relation-picker'
|
|
36
36
|
import { RelationSummary } from './relation-summary'
|
|
37
37
|
|
|
38
38
|
// A single stored item. On the edit path the loader's populate pass attaches
|
|
@@ -147,24 +147,29 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
147
147
|
return Array.isArray(defaultValue) ? (defaultValue as IncomingRelationValue[]) : []
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
const
|
|
151
|
-
targetDocumentId: string
|
|
152
|
-
targetCollectionId: string
|
|
153
|
-
record?: Record<string, any>
|
|
154
|
-
}) => {
|
|
150
|
+
const handleAddMany = (selections: RelationPickerSelection[]) => {
|
|
155
151
|
setPickerOpen(false)
|
|
156
152
|
const current = currentArray()
|
|
157
|
-
// Dedup — a target may appear at most
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
153
|
+
// Dedup the batch against the current array — a target may appear at most
|
|
154
|
+
// once. The picker already disables already-added rows, so this is a
|
|
155
|
+
// belt-and-braces guard for stale state (e.g. a concurrent remove).
|
|
156
|
+
const existing = new Set(current.map((v) => v.targetDocumentId))
|
|
157
|
+
const additions = selections.filter((s) => !existing.has(s.targetDocumentId))
|
|
158
|
+
if (additions.length === 0) return
|
|
159
|
+
const records = additions.filter((s) => s.record != null)
|
|
160
|
+
if (records.length > 0) {
|
|
161
|
+
setPickedRecords((prev) => {
|
|
162
|
+
const next = { ...prev }
|
|
163
|
+
for (const s of records) next[s.targetDocumentId] = s.record!
|
|
164
|
+
return next
|
|
165
|
+
})
|
|
161
166
|
}
|
|
162
167
|
setFieldValue(fieldPath, [
|
|
163
168
|
...current,
|
|
164
|
-
{
|
|
165
|
-
targetDocumentId:
|
|
166
|
-
targetCollectionId:
|
|
167
|
-
},
|
|
169
|
+
...additions.map((s) => ({
|
|
170
|
+
targetDocumentId: s.targetDocumentId,
|
|
171
|
+
targetCollectionId: s.targetCollectionId,
|
|
172
|
+
})),
|
|
168
173
|
])
|
|
169
174
|
}
|
|
170
175
|
|
|
@@ -260,11 +265,13 @@ export const RelationManyField = ({ field, defaultValue, id, path }: RelationMan
|
|
|
260
265
|
</div>
|
|
261
266
|
|
|
262
267
|
<RelationPicker
|
|
268
|
+
multiple
|
|
263
269
|
targetCollectionPath={field.targetCollection}
|
|
264
270
|
targetDefinition={targetDef}
|
|
265
271
|
displayField={field.displayField}
|
|
266
272
|
isOpen={pickerOpen}
|
|
267
|
-
|
|
273
|
+
excludeIds={items.map((v) => v.targetDocumentId)}
|
|
274
|
+
onSelectMany={handleAddMany}
|
|
268
275
|
onDismiss={() => setPickerOpen(false)}
|
|
269
276
|
/>
|
|
270
277
|
</>
|
|
@@ -108,6 +108,55 @@
|
|
|
108
108
|
border-left: 2px solid var(--primary-200);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/* Multi-select mode: rows lay out check indicator + content + trailing hint. */
|
|
112
|
+
.row-multi,
|
|
113
|
+
:global(.byline-field-relation-picker-row-multi) {
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
gap: 0.625rem;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.check,
|
|
120
|
+
:global(.byline-field-relation-picker-check) {
|
|
121
|
+
flex: none;
|
|
122
|
+
display: inline-flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
justify-content: center;
|
|
125
|
+
width: 16px;
|
|
126
|
+
height: 16px;
|
|
127
|
+
border: 1px solid var(--gray-400);
|
|
128
|
+
border-radius: 4px;
|
|
129
|
+
color: transparent;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.check-checked,
|
|
133
|
+
:global(.byline-field-relation-picker-check-checked) {
|
|
134
|
+
background-color: var(--primary-200);
|
|
135
|
+
border-color: var(--primary-200);
|
|
136
|
+
color: #fff;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Already-added rows: inert, dimmed, no hover affordance. */
|
|
140
|
+
.row-added,
|
|
141
|
+
:global(.byline-field-relation-picker-row-added) {
|
|
142
|
+
opacity: 0.55;
|
|
143
|
+
cursor: default;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.row-added:hover,
|
|
147
|
+
:global(.byline-field-relation-picker-row-added):hover {
|
|
148
|
+
background-color: transparent;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.added-hint,
|
|
152
|
+
:global(.byline-field-relation-picker-added-hint) {
|
|
153
|
+
margin-left: auto;
|
|
154
|
+
flex: none;
|
|
155
|
+
color: var(--gray-500);
|
|
156
|
+
font-size: var(--font-size-xs);
|
|
157
|
+
white-space: nowrap;
|
|
158
|
+
}
|
|
159
|
+
|
|
111
160
|
.row-cells,
|
|
112
161
|
:global(.byline-field-relation-picker-row-cells) {
|
|
113
162
|
display: flex;
|
|
@@ -11,7 +11,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|
|
11
11
|
import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
|
|
12
12
|
import { getCollectionAdminConfig, resolveItemViewColumns } from '@byline/core'
|
|
13
13
|
import { useTranslation } from '@byline/i18n/react'
|
|
14
|
-
import { Button, LoaderRing, Modal, Search } from '@byline/ui/react'
|
|
14
|
+
import { Button, CheckIcon, LoaderRing, Modal, Search } from '@byline/ui/react'
|
|
15
15
|
import cx from 'classnames'
|
|
16
16
|
|
|
17
17
|
import { useBylineFieldServices } from '../field-services-context'
|
|
@@ -39,7 +39,21 @@ import styles from './relation-picker.module.css'
|
|
|
39
39
|
*
|
|
40
40
|
* Paths 2–4 render a single-line label (primary) + `path` (secondary).
|
|
41
41
|
*/
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* One confirmed pick. `record` is the raw document the picker row rendered —
|
|
44
|
+
* the caller can use it to show the selected value in its own tile without a
|
|
45
|
+
* refetch. The fields available on `record` are whatever `resolveSelectFields`
|
|
46
|
+
* asked the listing endpoint for (picker columns + `useAsTitle` +
|
|
47
|
+
* `displayField`), so any display surface downstream of the picker that also
|
|
48
|
+
* renders from those same columns will find the data it needs.
|
|
49
|
+
*/
|
|
50
|
+
export interface RelationPickerSelection {
|
|
51
|
+
targetDocumentId: string
|
|
52
|
+
targetCollectionId: string
|
|
53
|
+
record?: Record<string, any>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RelationPickerBaseProps {
|
|
43
57
|
/** The target collection path (e.g. `'media'`). */
|
|
44
58
|
targetCollectionPath: string
|
|
45
59
|
/** The target collection definition (used for labels + displayField fallback). */
|
|
@@ -58,25 +72,38 @@ interface RelationPickerProps {
|
|
|
58
72
|
extraSelectFields?: string[]
|
|
59
73
|
/** Modal open/close state. */
|
|
60
74
|
isOpen: boolean
|
|
61
|
-
/**
|
|
62
|
-
* Called with the picked selection when the user confirms.
|
|
63
|
-
*
|
|
64
|
-
* `record` is the raw document the picker row rendered — the caller can
|
|
65
|
-
* use it to show the selected value in its own tile without a refetch.
|
|
66
|
-
* The fields available on `record` are whatever `resolveSelectFields`
|
|
67
|
-
* asked the listing endpoint for (picker columns + `useAsTitle` +
|
|
68
|
-
* `displayField`), so any display surface downstream of the picker that
|
|
69
|
-
* also renders from those same columns will find the data it needs.
|
|
70
|
-
*/
|
|
71
|
-
onSelect: (selection: {
|
|
72
|
-
targetDocumentId: string
|
|
73
|
-
targetCollectionId: string
|
|
74
|
-
record?: Record<string, any>
|
|
75
|
-
}) => void
|
|
76
75
|
/** Called when the user dismisses the modal. */
|
|
77
76
|
onDismiss: () => void
|
|
78
77
|
}
|
|
79
78
|
|
|
79
|
+
interface RelationPickerSingleProps extends RelationPickerBaseProps {
|
|
80
|
+
/** Single-select (default): clicking a row selects it; confirm returns one pick. */
|
|
81
|
+
multiple?: false
|
|
82
|
+
/** Called with the picked selection when the user confirms. */
|
|
83
|
+
onSelect: (selection: RelationPickerSelection) => void
|
|
84
|
+
onSelectMany?: never
|
|
85
|
+
excludeIds?: never
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface RelationPickerMultiProps extends RelationPickerBaseProps {
|
|
89
|
+
/**
|
|
90
|
+
* Multi-select mode (`hasMany` widgets): rows toggle a check state and the
|
|
91
|
+
* confirm action returns every selection in pick order — several picks in
|
|
92
|
+
* one trip instead of reopening the modal per item.
|
|
93
|
+
*/
|
|
94
|
+
multiple: true
|
|
95
|
+
/** Called with the full selection set (pick order) when the user confirms. */
|
|
96
|
+
onSelectMany: (selections: RelationPickerSelection[]) => void
|
|
97
|
+
onSelect?: never
|
|
98
|
+
/**
|
|
99
|
+
* Target ids already present on the caller's value. Rendered as disabled
|
|
100
|
+
* "already added" rows so the same target can't be picked twice.
|
|
101
|
+
*/
|
|
102
|
+
excludeIds?: string[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type RelationPickerProps = RelationPickerSingleProps | RelationPickerMultiProps
|
|
106
|
+
|
|
80
107
|
const PAGE_SIZE = 15
|
|
81
108
|
|
|
82
109
|
export const RelationPicker = ({
|
|
@@ -85,13 +112,22 @@ export const RelationPicker = ({
|
|
|
85
112
|
displayField,
|
|
86
113
|
extraSelectFields,
|
|
87
114
|
isOpen,
|
|
115
|
+
multiple = false,
|
|
116
|
+
excludeIds,
|
|
88
117
|
onSelect,
|
|
118
|
+
onSelectMany,
|
|
89
119
|
onDismiss,
|
|
90
120
|
}: RelationPickerProps) => {
|
|
91
121
|
const [query, setQuery] = useState<string>('')
|
|
92
122
|
const [page, setPage] = useState<number>(1)
|
|
93
123
|
const { t } = useTranslation('byline-admin')
|
|
94
124
|
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
|
|
125
|
+
// Multi-select state. A Map keyed by target id preserves pick order (the
|
|
126
|
+
// confirmed batch appends in that order) and carries each row's record so
|
|
127
|
+
// picks survive page/search changes that swap out `documents`.
|
|
128
|
+
const [selectedMap, setSelectedMap] = useState<Map<string, Record<string, any> | undefined>>(
|
|
129
|
+
() => new Map()
|
|
130
|
+
)
|
|
95
131
|
const [loading, setLoading] = useState<boolean>(false)
|
|
96
132
|
const [error, setError] = useState<string | null>(null)
|
|
97
133
|
const [documents, setDocuments] = useState<any[]>([])
|
|
@@ -110,6 +146,7 @@ export const RelationPicker = ({
|
|
|
110
146
|
setQuery('')
|
|
111
147
|
setPage(1)
|
|
112
148
|
setSelectedDocumentId(null)
|
|
149
|
+
setSelectedMap(new Map())
|
|
113
150
|
setError(null)
|
|
114
151
|
}
|
|
115
152
|
}, [isOpen])
|
|
@@ -174,7 +211,7 @@ export const RelationPicker = ({
|
|
|
174
211
|
null
|
|
175
212
|
|
|
176
213
|
const handleSelect = useCallback(() => {
|
|
177
|
-
if (!selectedDocumentId || !collectionId) return
|
|
214
|
+
if (!selectedDocumentId || !collectionId || !onSelect) return
|
|
178
215
|
const record = documents.find((d) => d?.id === selectedDocumentId)
|
|
179
216
|
onSelect({
|
|
180
217
|
targetDocumentId: selectedDocumentId,
|
|
@@ -183,6 +220,30 @@ export const RelationPicker = ({
|
|
|
183
220
|
})
|
|
184
221
|
}, [selectedDocumentId, collectionId, documents, onSelect])
|
|
185
222
|
|
|
223
|
+
const handleSelectMany = useCallback(() => {
|
|
224
|
+
if (selectedMap.size === 0 || !collectionId || !onSelectMany) return
|
|
225
|
+
onSelectMany(
|
|
226
|
+
Array.from(selectedMap, ([targetDocumentId, record]) => ({
|
|
227
|
+
targetDocumentId,
|
|
228
|
+
targetCollectionId: collectionId,
|
|
229
|
+
record,
|
|
230
|
+
}))
|
|
231
|
+
)
|
|
232
|
+
}, [selectedMap, collectionId, onSelectMany])
|
|
233
|
+
|
|
234
|
+
const toggleSelected = useCallback((doc: Record<string, any>) => {
|
|
235
|
+
const id = doc.id as string
|
|
236
|
+
setSelectedMap((prev) => {
|
|
237
|
+
const next = new Map(prev)
|
|
238
|
+
if (next.has(id)) {
|
|
239
|
+
next.delete(id)
|
|
240
|
+
} else {
|
|
241
|
+
next.set(id, doc)
|
|
242
|
+
}
|
|
243
|
+
return next
|
|
244
|
+
})
|
|
245
|
+
}, [])
|
|
246
|
+
|
|
186
247
|
const title = t('fields.relation.selectPickerTitle', {
|
|
187
248
|
label: targetDefinition?.labels.singular ?? targetCollectionPath,
|
|
188
249
|
})
|
|
@@ -228,21 +289,55 @@ export const RelationPicker = ({
|
|
|
228
289
|
<ul className={cx('byline-field-relation-picker-rows', styles.rows)}>
|
|
229
290
|
{documents.map((doc) => {
|
|
230
291
|
const id = doc.id as string
|
|
231
|
-
const selected = selectedDocumentId === id
|
|
292
|
+
const selected = multiple ? selectedMap.has(id) : selectedDocumentId === id
|
|
293
|
+
const excluded = multiple && (excludeIds?.includes(id) ?? false)
|
|
232
294
|
return (
|
|
233
295
|
<li key={id}>
|
|
234
296
|
<button
|
|
235
297
|
type="button"
|
|
298
|
+
disabled={excluded}
|
|
299
|
+
aria-pressed={multiple ? selected : undefined}
|
|
300
|
+
title={excluded ? t('fields.relation.picker.alreadyAdded') : undefined}
|
|
236
301
|
className={cx(
|
|
237
302
|
'byline-field-relation-picker-row-button',
|
|
238
303
|
styles['row-button'],
|
|
304
|
+
multiple && [
|
|
305
|
+
'byline-field-relation-picker-row-multi',
|
|
306
|
+
styles['row-multi'],
|
|
307
|
+
],
|
|
239
308
|
selected && [
|
|
240
309
|
'byline-field-relation-picker-row-selected',
|
|
241
310
|
styles['row-selected'],
|
|
311
|
+
],
|
|
312
|
+
excluded && [
|
|
313
|
+
'byline-field-relation-picker-row-added',
|
|
314
|
+
styles['row-added'],
|
|
242
315
|
]
|
|
243
316
|
)}
|
|
244
|
-
onClick={() =>
|
|
317
|
+
onClick={() => {
|
|
318
|
+
if (excluded) return
|
|
319
|
+
if (multiple) {
|
|
320
|
+
toggleSelected(doc)
|
|
321
|
+
} else {
|
|
322
|
+
setSelectedDocumentId(id)
|
|
323
|
+
}
|
|
324
|
+
}}
|
|
245
325
|
>
|
|
326
|
+
{multiple && (
|
|
327
|
+
<span
|
|
328
|
+
aria-hidden="true"
|
|
329
|
+
className={cx(
|
|
330
|
+
'byline-field-relation-picker-check',
|
|
331
|
+
styles.check,
|
|
332
|
+
(selected || excluded) && [
|
|
333
|
+
'byline-field-relation-picker-check-checked',
|
|
334
|
+
styles['check-checked'],
|
|
335
|
+
]
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
338
|
+
{(selected || excluded) && <CheckIcon width="12px" height="12px" />}
|
|
339
|
+
</span>
|
|
340
|
+
)}
|
|
246
341
|
{pickerColumns && pickerColumns.length > 0 ? (
|
|
247
342
|
<div
|
|
248
343
|
className={cx(
|
|
@@ -281,6 +376,16 @@ export const RelationPicker = ({
|
|
|
281
376
|
)}
|
|
282
377
|
</div>
|
|
283
378
|
)}
|
|
379
|
+
{excluded && (
|
|
380
|
+
<span
|
|
381
|
+
className={cx(
|
|
382
|
+
'byline-field-relation-picker-added-hint',
|
|
383
|
+
styles['added-hint']
|
|
384
|
+
)}
|
|
385
|
+
>
|
|
386
|
+
{t('fields.relation.picker.alreadyAdded')}
|
|
387
|
+
</span>
|
|
388
|
+
)}
|
|
284
389
|
</button>
|
|
285
390
|
</li>
|
|
286
391
|
)
|
|
@@ -331,10 +436,12 @@ export const RelationPicker = ({
|
|
|
331
436
|
className={cx('byline-field-relation-picker-action', styles.action)}
|
|
332
437
|
intent="primary"
|
|
333
438
|
type="button"
|
|
334
|
-
disabled={!selectedDocumentId}
|
|
335
|
-
onClick={handleSelect}
|
|
439
|
+
disabled={multiple ? selectedMap.size === 0 : !selectedDocumentId}
|
|
440
|
+
onClick={multiple ? handleSelectMany : handleSelect}
|
|
336
441
|
>
|
|
337
|
-
{
|
|
442
|
+
{multiple
|
|
443
|
+
? t('fields.relation.picker.addSelected', { count: selectedMap.size })
|
|
444
|
+
: t('common.actions.select')}
|
|
338
445
|
</Button>
|
|
339
446
|
</Modal.Actions>
|
|
340
447
|
</Modal.Container>
|
|
@@ -14,7 +14,7 @@ import { reconcileLocaleState } from './available-locales-reconcile.js'
|
|
|
14
14
|
* The widget's reconciliation is expressed purely as Checkbox intent +
|
|
15
15
|
* disabled state — no per-row text. `reconcileLocaleState` is the pure heart
|
|
16
16
|
* of that mapping; the four cells below are the full truth table from
|
|
17
|
-
* docs/
|
|
17
|
+
* docs/07-internationalization/index.md.
|
|
18
18
|
*/
|
|
19
19
|
describe('reconcileLocaleState', () => {
|
|
20
20
|
it('ledger-complete + advertised → green, enabled (advertised & complete)', () => {
|
|
@@ -64,7 +64,7 @@ const sameLocaleSet = (a: string[], b: string[]): boolean => {
|
|
|
64
64
|
* Save button. `content` mints a new version (normal workflow). `direct-write`
|
|
65
65
|
* is an immediate, non-versioned write of the document-grain system fields
|
|
66
66
|
* (path / advertised locales) that does NOT reset workflow status. `both` does
|
|
67
|
-
* each through its own write path. See docs/
|
|
67
|
+
* each through its own write path. See docs/07-internationalization/index.md.
|
|
68
68
|
*/
|
|
69
69
|
export type DirtyReason = 'none' | 'content' | 'direct-write' | 'both'
|
|
70
70
|
|
|
@@ -103,7 +103,7 @@ interface FormContextType {
|
|
|
103
103
|
/**
|
|
104
104
|
* Partition the current dirty state into content vs. system-field (path /
|
|
105
105
|
* advertised-locales) writes so the Save button can branch. See
|
|
106
|
-
* docs/
|
|
106
|
+
* docs/07-internationalization/index.md.
|
|
107
107
|
*/
|
|
108
108
|
getDirtyBreakdown: () => DirtyBreakdown
|
|
109
109
|
subscribeField: (name: string, listener: FieldListener) => () => void
|
|
@@ -349,7 +349,7 @@ export const FormProvider = ({
|
|
|
349
349
|
// button can route each piece correctly: content → versioned write; the
|
|
350
350
|
// document-grain system fields (path / advertised locales) → immediate,
|
|
351
351
|
// non-versioned direct write that leaves workflow status untouched.
|
|
352
|
-
// See docs/
|
|
352
|
+
// See docs/07-internationalization/index.md.
|
|
353
353
|
const getDirtyBreakdown = useCallback((): DirtyBreakdown => {
|
|
354
354
|
const keys = dirtyFields.current
|
|
355
355
|
const pathDirty = keys.has(SYSTEM_PATH_DIRTY_KEY)
|
|
@@ -54,7 +54,7 @@ export const UnsavedChangesModal = ({ onClose }: { onClose: () => void }) => {
|
|
|
54
54
|
* Confirms an immediate, non-versioned write of the document-grain system
|
|
55
55
|
* fields (path / advertised locales) — which does NOT reset workflow status.
|
|
56
56
|
* When content is also dirty, the copy reassures that content edits still
|
|
57
|
-
* follow the normal revision + publish workflow. See docs/
|
|
57
|
+
* follow the normal revision + publish workflow. See docs/07-internationalization/index.md.
|
|
58
58
|
*/
|
|
59
59
|
export const SystemFieldsConfirmModal = ({
|
|
60
60
|
contentDirty,
|
|
@@ -56,7 +56,7 @@ export interface PublishedVersionInfo {
|
|
|
56
56
|
* patches) alongside the document-grain system fields (path / advertised
|
|
57
57
|
* locales) and per-bucket dirty flags so the host can route each piece to the
|
|
58
58
|
* right write path — versioned for content, immediate/non-versioned for the
|
|
59
|
-
* system fields. See docs/
|
|
59
|
+
* system fields. See docs/07-internationalization/index.md.
|
|
60
60
|
*/
|
|
61
61
|
export interface SystemFieldsSubmitPayload {
|
|
62
62
|
// biome-ignore lint/suspicious/noExplicitAny: data is collection-specific
|
|
@@ -134,7 +134,7 @@ export interface FormRendererProps {
|
|
|
134
134
|
* Opts the document-tree placement widget into the sidebar (above the
|
|
135
135
|
* available-locales widget). Sourced from `CollectionDefinition.tree` by the
|
|
136
136
|
* caller. Renders only in edit mode (placement needs a persisted document)
|
|
137
|
-
* and only when the host wires the tree services. See docs/
|
|
137
|
+
* and only when the host wires the tree services. See docs/04-collections/03-document-trees.md.
|
|
138
138
|
*/
|
|
139
139
|
tree?: boolean
|
|
140
140
|
headingLabel?: string
|
|
@@ -235,7 +235,7 @@ const FormContent = ({
|
|
|
235
235
|
const [showUnsavedModal, setShowUnsavedModal] = useState(false)
|
|
236
236
|
// Holds the pending Save payload while the editor confirms an immediate,
|
|
237
237
|
// non-versioned system-field write (path / advertised locales). Non-null
|
|
238
|
-
// means the confirmation modal is open. See docs/
|
|
238
|
+
// means the confirmation modal is open. See docs/07-internationalization/index.md.
|
|
239
239
|
const [pendingSystemFieldsSubmit, setPendingSystemFieldsSubmit] =
|
|
240
240
|
useState<SystemFieldsSubmitPayload | null>(null)
|
|
241
241
|
const [contentLocale, setContentLocale] = useState(initialLocale ?? defaultLocale)
|
|
@@ -532,7 +532,7 @@ const FormContent = ({
|
|
|
532
532
|
{/* Source-locale anchor indicator removed pending heading-layout work.
|
|
533
533
|
To re-enable: render `<SourceLocaleBadge locale={sourceLocale} />`
|
|
534
534
|
here from `initialData.sourceLocale` (mismatch-only is the intended
|
|
535
|
-
end state). See docs/
|
|
535
|
+
end state). See docs/07-internationalization/index.md. */}
|
|
536
536
|
{headerSlot}
|
|
537
537
|
</div>
|
|
538
538
|
<div className={cx('byline-form-status-bar', styles['status-bar'])}>
|
|
@@ -30,7 +30,7 @@ export interface TreePlacementWidgetProps {
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Sidebar widget for placing the current document within its collection's
|
|
33
|
-
* single-parent document tree (the `tree: true` primitive — docs/
|
|
33
|
+
* single-parent document tree (the `tree: true` primitive — docs/04-collections/03-document-trees.md).
|
|
34
34
|
*
|
|
35
35
|
* The tree is document-grain and **unversioned**, so changes here write
|
|
36
36
|
* immediately (independent of the form's content save). The editor picks a
|
|
@@ -16,7 +16,7 @@ import { assertAdminActor, requireAdminActor } from './assert-admin-actor.js'
|
|
|
16
16
|
* contract (validate → authorise → invoke → shape) into a single
|
|
17
17
|
* declaration.
|
|
18
18
|
*
|
|
19
|
-
* Implements Phase 1 of `docs/
|
|
19
|
+
* Implements Phase 1 of `docs/03-architecture/02-core-composition.md`. Today's scope is
|
|
20
20
|
* `@byline/admin`-internal: it gates against admin actor identity using
|
|
21
21
|
* the existing `assertAdminActor` / `requireAdminActor` helpers, which
|
|
22
22
|
* inherit the super-admin bypass from `AdminAuth.assertAbility`.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { AbilityRegistry } from '@byline/auth'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Ability keys for the admin-activity module (docs/
|
|
12
|
+
* Ability keys for the admin-activity module (docs/06-auth-and-security/02-auditability.md — Workstream 4).
|
|
13
13
|
*
|
|
14
14
|
* `read` gates the system-wide activity area — the `/admin/activity` report
|
|
15
15
|
* over the version-stream + audit-log union. It is deliberately a **separate**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* `@byline/admin/admin-activity` — the system-wide activity area
|
|
11
|
-
* (docs/
|
|
11
|
+
* (docs/06-auth-and-security/02-auditability.md — Workstream 4).
|
|
12
12
|
*
|
|
13
13
|
* Unlike the other admin modules this one owns no table and no AdminStore
|
|
14
14
|
* repository: the activity feed is a read over the document db adapter's
|
|
@@ -11,7 +11,7 @@ import type { AbilityRegistry } from '@byline/auth'
|
|
|
11
11
|
/**
|
|
12
12
|
* Ability keys for the admin-permissions module.
|
|
13
13
|
*
|
|
14
|
-
* `read` gates the inspector view (see docs/
|
|
14
|
+
* `read` gates the inspector view (see docs/06-auth-and-security/01-authn-authz.md).
|
|
15
15
|
* `update` will gate the per-role ability editor mounted on the
|
|
16
16
|
* admin-roles role detail page — declared here so the role editor can
|
|
17
17
|
* assert against it once that surface lands. The per-role editor shares
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Read-only abilities inspector — see docs/
|
|
12
|
+
* Read-only abilities inspector — see docs/06-auth-and-security/01-authn-authz.md.
|
|
13
13
|
*
|
|
14
14
|
* Top level: a collapsible group per ability source (collections.docs,
|
|
15
15
|
* admin.users, etc.), each containing the abilities that group
|
|
@@ -128,7 +128,7 @@ export interface AdminUsersRepository {
|
|
|
128
128
|
getById(id: string): Promise<AdminUserRow | null>
|
|
129
129
|
/**
|
|
130
130
|
* Bulk lookup for audit actor-label resolution (the `actors`
|
|
131
|
-
* map on admin document reads — see docs/
|
|
131
|
+
* map on admin document reads — see docs/06-auth-and-security/02-auditability.md, Workstream 1). Ids
|
|
132
132
|
* with no matching row are simply absent from the result; callers
|
|
133
133
|
* render a tombstone label for them.
|
|
134
134
|
*/
|
|
@@ -27,7 +27,7 @@ export interface SourceLocaleBadgeProps {
|
|
|
27
27
|
* NOTE: currently rendered for *every* document so the anchor is visible during
|
|
28
28
|
* development. The intended end state is to show it only when `locale` differs
|
|
29
29
|
* from the system's current default content locale (a normal single-default
|
|
30
|
-
* install then shows nothing). See docs/
|
|
30
|
+
* install then shows nothing). See docs/07-internationalization/index.md.
|
|
31
31
|
*
|
|
32
32
|
* Stable override handle: `.byline-source-locale-badge`.
|
|
33
33
|
*/
|