@byline/admin 3.13.3 → 3.15.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-renderer.js +10 -0
- package/dist/fields/relation/relation-column-formatter.d.ts +28 -0
- package/dist/fields/relation/relation-column-formatter.js +47 -0
- package/dist/fields/relation/relation-field.module.js +16 -1
- package/dist/fields/relation/relation-field_module.css +64 -0
- package/dist/fields/relation/relation-many-field.d.ts +16 -0
- package/dist/fields/relation/relation-many-field.js +193 -0
- package/dist/fields/relation/relation-picker.js +2 -2
- package/dist/fields/relation/relation-summary.js +2 -1
- package/dist/react.d.ts +2 -0
- package/dist/react.js +2 -0
- package/package.json +5 -5
- package/src/fields/field-renderer.tsx +11 -0
- package/src/fields/relation/relation-column-formatter.tsx +119 -0
- package/src/fields/relation/relation-field.module.css +81 -0
- package/src/fields/relation/relation-many-field.tsx +274 -0
- package/src/fields/relation/relation-picker.tsx +2 -2
- package/src/fields/relation/relation-summary.tsx +3 -2
- package/src/react.ts +2 -0
|
@@ -12,6 +12,7 @@ import { ImageField } from "./image/image-field.js";
|
|
|
12
12
|
import { LocaleBadge } from "./locale-badge.js";
|
|
13
13
|
import { NumericalField } from "./numerical/numerical-field.js";
|
|
14
14
|
import { RelationField } from "./relation/relation-field.js";
|
|
15
|
+
import { RelationManyField } from "./relation/relation-many-field.js";
|
|
15
16
|
import { SelectField } from "./select/select-field.js";
|
|
16
17
|
import { TextField } from "./text/text-field.js";
|
|
17
18
|
import { TextAreaField } from "./text-area/text-area-field.js";
|
|
@@ -152,6 +153,15 @@ const FieldRenderer = ({ field, defaultValue, basePath, disableSorting, hideLabe
|
|
|
152
153
|
collectionPath: collectionPath
|
|
153
154
|
});
|
|
154
155
|
case 'relation':
|
|
156
|
+
if (field.hasMany) return /*#__PURE__*/ jsx(RelationManyField, {
|
|
157
|
+
field: hideLabel ? {
|
|
158
|
+
...field,
|
|
159
|
+
label: void 0
|
|
160
|
+
} : field,
|
|
161
|
+
defaultValue: defaultValue,
|
|
162
|
+
path: path,
|
|
163
|
+
id: htmlId
|
|
164
|
+
});
|
|
155
165
|
return /*#__PURE__*/ jsx(RelationField, {
|
|
156
166
|
field: hideLabel ? {
|
|
157
167
|
...field,
|
|
@@ -0,0 +1,28 @@
|
|
|
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 type { CollectionDefinition, ColumnDefinition, ColumnFormatter, RelationField } from '@byline/core';
|
|
9
|
+
/**
|
|
10
|
+
* Pure text projection of a relation cell: the target's title for a single
|
|
11
|
+
* relation, or "A, B, +N more" for `hasMany`. Unresolved targets are skipped;
|
|
12
|
+
* an all-empty cell yields `null` (the formatter renders a muted dash).
|
|
13
|
+
*
|
|
14
|
+
* `displayField` is the resolved chain
|
|
15
|
+
* (`field.displayField` → target `useAsTitle` → first text field).
|
|
16
|
+
*/
|
|
17
|
+
export declare function formatRelationCellText(field: Pick<RelationField, 'hasMany'>, value: unknown, displayField: string | null): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* The built-in `{ component }` column formatter for a `relation` field. Resolves
|
|
20
|
+
* the target collection's `useAsTitle` at render time and renders the cell text.
|
|
21
|
+
*/
|
|
22
|
+
export declare function relationColumnFormatter(field: RelationField): ColumnFormatter;
|
|
23
|
+
/**
|
|
24
|
+
* Return a copy of `columns` with {@link relationColumnFormatter} applied to any
|
|
25
|
+
* `relation`-typed column that doesn't already declare its own formatter, so
|
|
26
|
+
* relation cells render target titles by default. Other columns are untouched.
|
|
27
|
+
*/
|
|
28
|
+
export declare function applyRelationColumnFormatters(columns: ColumnDefinition[], definition: CollectionDefinition): ColumnDefinition[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { getCollectionDefinition } from "@byline/core";
|
|
3
|
+
import { resolveFallbackDisplayField, resolveRowLabel } from "./relation-display.js";
|
|
4
|
+
const MAX_SHOWN = 3;
|
|
5
|
+
function labelFor(env, displayField) {
|
|
6
|
+
if (null == env || false === env._resolved || null == env.document) return null;
|
|
7
|
+
return resolveRowLabel(env.document, displayField);
|
|
8
|
+
}
|
|
9
|
+
function formatRelationCellText(field, value, displayField) {
|
|
10
|
+
const envelopes = field.hasMany ? Array.isArray(value) ? value : [] : value ? [
|
|
11
|
+
value
|
|
12
|
+
] : [];
|
|
13
|
+
const labels = envelopes.map((env)=>labelFor(env, displayField)).filter((l)=>null != l && l.length > 0);
|
|
14
|
+
if (0 === labels.length) return null;
|
|
15
|
+
if (!field.hasMany) return labels[0] ?? null;
|
|
16
|
+
const shown = labels.slice(0, MAX_SHOWN);
|
|
17
|
+
const extra = labels.length - shown.length;
|
|
18
|
+
return extra > 0 ? `${shown.join(', ')}, +${extra} more` : shown.join(', ');
|
|
19
|
+
}
|
|
20
|
+
function relationColumnFormatter(field) {
|
|
21
|
+
return {
|
|
22
|
+
component: ({ value })=>{
|
|
23
|
+
const targetDef = getCollectionDefinition(field.targetCollection);
|
|
24
|
+
const displayField = field.displayField ?? targetDef?.useAsTitle ?? resolveFallbackDisplayField(targetDef);
|
|
25
|
+
const text = formatRelationCellText(field, value, displayField);
|
|
26
|
+
return null != text ? /*#__PURE__*/ jsx("span", {
|
|
27
|
+
className: "byline-relation-column",
|
|
28
|
+
children: text
|
|
29
|
+
}) : /*#__PURE__*/ jsx("span", {
|
|
30
|
+
className: "byline-relation-column-empty muted",
|
|
31
|
+
children: "—"
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function applyRelationColumnFormatters(columns, definition) {
|
|
37
|
+
return columns.map((col)=>{
|
|
38
|
+
if (col.formatter) return col;
|
|
39
|
+
const field = definition.fields.find((f)=>f.name === String(col.fieldName));
|
|
40
|
+
if (field?.type === 'relation') return {
|
|
41
|
+
...col,
|
|
42
|
+
formatter: relationColumnFormatter(field)
|
|
43
|
+
};
|
|
44
|
+
return col;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export { applyRelationColumnFormatters, formatRelationCellText, relationColumnFormatter };
|
|
@@ -8,6 +8,21 @@ const relation_field_module = {
|
|
|
8
8
|
errorText: "error-text-jHQh34",
|
|
9
9
|
tile: "tile-Y3_yre",
|
|
10
10
|
actions: "actions-Nov8hS",
|
|
11
|
-
mono: "mono-y8Xo6b"
|
|
11
|
+
mono: "mono-y8Xo6b",
|
|
12
|
+
many: "many-ohEmr7",
|
|
13
|
+
"many-empty": "many-empty-T0OJd9",
|
|
14
|
+
manyEmpty: "many-empty-T0OJd9",
|
|
15
|
+
"many-tile": "many-tile-jNifSf",
|
|
16
|
+
manyTile: "many-tile-jNifSf",
|
|
17
|
+
"many-tile-dragging": "many-tile-dragging-AUDjxQ",
|
|
18
|
+
manyTileDragging: "many-tile-dragging-AUDjxQ",
|
|
19
|
+
"many-grip": "many-grip-i7LGT9",
|
|
20
|
+
manyGrip: "many-grip-i7LGT9",
|
|
21
|
+
"many-body": "many-body-e8JKV8",
|
|
22
|
+
manyBody: "many-body-e8JKV8",
|
|
23
|
+
"many-remove": "many-remove-fuuKiO",
|
|
24
|
+
manyRemove: "many-remove-fuuKiO",
|
|
25
|
+
"many-add": "many-add-c5uQ7B",
|
|
26
|
+
manyAdd: "many-add-c5uQ7B"
|
|
12
27
|
};
|
|
13
28
|
export default relation_field_module;
|
|
@@ -60,3 +60,67 @@
|
|
|
60
60
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
:is(.many-ohEmr7, .byline-field-relation-many) {
|
|
64
|
+
gap: var(--spacing-4);
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
margin-top: .25rem;
|
|
67
|
+
display: flex;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
:is(.many-empty-T0OJd9, .byline-field-relation-many-empty) {
|
|
71
|
+
color: var(--gray-400);
|
|
72
|
+
font-size: var(--font-size-xs);
|
|
73
|
+
margin: .25rem 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
:is(.many-tile-jNifSf, .byline-field-relation-many-tile) {
|
|
77
|
+
align-items: center;
|
|
78
|
+
gap: var(--spacing-8);
|
|
79
|
+
padding: var(--spacing-4) var(--spacing-8);
|
|
80
|
+
border: var(--border-width-thin) var(--border-style-solid) var(--primary-500);
|
|
81
|
+
border-radius: var(--border-radius-md);
|
|
82
|
+
background-color: var(--surface-100, transparent);
|
|
83
|
+
color: var(--gray-200);
|
|
84
|
+
font-size: var(--font-size-xs);
|
|
85
|
+
display: flex;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
:is(.many-tile-dragging-AUDjxQ, .byline-field-relation-many-tile-dragging) {
|
|
89
|
+
opacity: .85;
|
|
90
|
+
box-shadow: var(--shadow-md, 0 4px 12px #0003);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
:is(.many-grip-i7LGT9, .byline-field-relation-many-grip) {
|
|
94
|
+
color: var(--gray-400);
|
|
95
|
+
cursor: grab;
|
|
96
|
+
touch-action: none;
|
|
97
|
+
background: none;
|
|
98
|
+
border: none;
|
|
99
|
+
flex-shrink: 0;
|
|
100
|
+
align-items: center;
|
|
101
|
+
padding: 0;
|
|
102
|
+
display: inline-flex;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.many-grip-i7LGT9:active {
|
|
106
|
+
cursor: grabbing;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.byline-field-relation-many-grip:active {
|
|
110
|
+
cursor: grabbing;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
:is(.many-body-e8JKV8, .byline-field-relation-many-body) {
|
|
114
|
+
flex: auto;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
:is(.many-remove-fuuKiO, .byline-field-relation-many-remove) {
|
|
119
|
+
flex-shrink: 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
:is(.many-add-c5uQ7B, .byline-field-relation-many-add) {
|
|
123
|
+
margin-top: .25rem;
|
|
124
|
+
display: flex;
|
|
125
|
+
}
|
|
126
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
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 type { RelationField as FieldType, RelatedDocumentValue } from '@byline/core';
|
|
9
|
+
interface RelationManyFieldProps {
|
|
10
|
+
field: FieldType;
|
|
11
|
+
defaultValue?: RelatedDocumentValue[] | null;
|
|
12
|
+
id?: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const RelationManyField: ({ field, defaultValue, id, path }: RelationManyFieldProps) => import("react").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { getCollectionAdminConfig, getCollectionDefinition } from "@byline/core";
|
|
4
|
+
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
+
import { Button, CloseIcon, DraggableSortable, ErrorText, GripperVerticalIcon, IconButton, Label, PlusIcon, moveItem, useSortable } from "@byline/ui/react";
|
|
6
|
+
import classnames from "classnames";
|
|
7
|
+
import { useFieldError, useFieldValue, useFormContext } from "../../forms/form-context.js";
|
|
8
|
+
import relation_field_module from "./relation-field.module.js";
|
|
9
|
+
import { RelationPicker } from "./relation-picker.js";
|
|
10
|
+
import { RelationSummary } from "./relation-summary.js";
|
|
11
|
+
const RelationManyTile = ({ id, children, onRemove, removeAriaLabel })=>{
|
|
12
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
13
|
+
id,
|
|
14
|
+
transition: {
|
|
15
|
+
duration: 250,
|
|
16
|
+
easing: 'cubic-bezier(0, 0.2, 0.2, 1)'
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
const style = {
|
|
20
|
+
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : void 0,
|
|
21
|
+
transition,
|
|
22
|
+
zIndex: isDragging ? 10 : 'auto'
|
|
23
|
+
};
|
|
24
|
+
return /*#__PURE__*/ jsxs("div", {
|
|
25
|
+
ref: setNodeRef,
|
|
26
|
+
style: style,
|
|
27
|
+
className: classnames('byline-field-relation-many-tile', relation_field_module["many-tile"], isDragging && [
|
|
28
|
+
'byline-field-relation-many-tile-dragging',
|
|
29
|
+
relation_field_module["many-tile-dragging"]
|
|
30
|
+
]),
|
|
31
|
+
children: [
|
|
32
|
+
/*#__PURE__*/ jsx("button", {
|
|
33
|
+
type: "button",
|
|
34
|
+
className: classnames('byline-field-relation-many-grip', relation_field_module["many-grip"]),
|
|
35
|
+
...attributes,
|
|
36
|
+
...listeners,
|
|
37
|
+
children: /*#__PURE__*/ jsx(GripperVerticalIcon, {})
|
|
38
|
+
}),
|
|
39
|
+
/*#__PURE__*/ jsx("div", {
|
|
40
|
+
className: classnames('byline-field-relation-many-body', relation_field_module["many-body"]),
|
|
41
|
+
children: children
|
|
42
|
+
}),
|
|
43
|
+
/*#__PURE__*/ jsx(IconButton, {
|
|
44
|
+
type: "button",
|
|
45
|
+
size: "xs",
|
|
46
|
+
intent: "noeffect",
|
|
47
|
+
"aria-label": removeAriaLabel,
|
|
48
|
+
className: classnames('byline-field-relation-many-remove', relation_field_module["many-remove"]),
|
|
49
|
+
onClick: onRemove,
|
|
50
|
+
children: /*#__PURE__*/ jsx(CloseIcon, {
|
|
51
|
+
width: "14px",
|
|
52
|
+
height: "14px"
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
]
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const RelationManyField = ({ field, defaultValue, id, path })=>{
|
|
59
|
+
const fieldPath = path ?? field.name;
|
|
60
|
+
const htmlId = id ?? fieldPath;
|
|
61
|
+
const { t } = useTranslation('byline-admin');
|
|
62
|
+
const fieldError = useFieldError(fieldPath);
|
|
63
|
+
const { getFieldValue, setFieldValue } = useFormContext();
|
|
64
|
+
const targetDef = getCollectionDefinition(field.targetCollection);
|
|
65
|
+
const targetAdminConfig = getCollectionAdminConfig(field.targetCollection);
|
|
66
|
+
const isUnknown = null == targetDef;
|
|
67
|
+
const targetLabel = targetDef?.labels.singular ?? field.targetCollection;
|
|
68
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
69
|
+
const [pickedRecords, setPickedRecords] = useState({});
|
|
70
|
+
const liveValue = useFieldValue(fieldPath);
|
|
71
|
+
const items = Array.isArray(liveValue) ? liveValue : Array.isArray(defaultValue) ? defaultValue : [];
|
|
72
|
+
const currentArray = ()=>{
|
|
73
|
+
const v = getFieldValue(fieldPath);
|
|
74
|
+
if (Array.isArray(v)) return v;
|
|
75
|
+
return Array.isArray(defaultValue) ? defaultValue : [];
|
|
76
|
+
};
|
|
77
|
+
const handleAdd = (selection)=>{
|
|
78
|
+
setPickerOpen(false);
|
|
79
|
+
const current = currentArray();
|
|
80
|
+
if (current.some((v)=>v.targetDocumentId === selection.targetDocumentId)) return;
|
|
81
|
+
if (selection.record) setPickedRecords((prev)=>({
|
|
82
|
+
...prev,
|
|
83
|
+
[selection.targetDocumentId]: selection.record
|
|
84
|
+
}));
|
|
85
|
+
setFieldValue(fieldPath, [
|
|
86
|
+
...current,
|
|
87
|
+
{
|
|
88
|
+
targetDocumentId: selection.targetDocumentId,
|
|
89
|
+
targetCollectionId: selection.targetCollectionId
|
|
90
|
+
}
|
|
91
|
+
]);
|
|
92
|
+
};
|
|
93
|
+
const handleRemove = (targetDocumentId)=>{
|
|
94
|
+
setFieldValue(fieldPath, currentArray().filter((v)=>v.targetDocumentId !== targetDocumentId));
|
|
95
|
+
};
|
|
96
|
+
const handleDragEnd = ({ moveFromIndex, moveToIndex })=>{
|
|
97
|
+
if (moveFromIndex === moveToIndex) return;
|
|
98
|
+
setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex));
|
|
99
|
+
};
|
|
100
|
+
return /*#__PURE__*/ jsxs("div", {
|
|
101
|
+
className: `byline-field-relation ${field.name}`,
|
|
102
|
+
children: [
|
|
103
|
+
/*#__PURE__*/ jsx("div", {
|
|
104
|
+
className: classnames('byline-field-relation-header', relation_field_module.header),
|
|
105
|
+
children: /*#__PURE__*/ jsx(Label, {
|
|
106
|
+
id: `${htmlId}-label`,
|
|
107
|
+
htmlFor: htmlId,
|
|
108
|
+
label: field.label ?? field.name,
|
|
109
|
+
required: !field.optional
|
|
110
|
+
})
|
|
111
|
+
}),
|
|
112
|
+
field.helpText && /*#__PURE__*/ jsx("div", {
|
|
113
|
+
className: classnames('byline-field-relation-help', relation_field_module.help),
|
|
114
|
+
children: field.helpText
|
|
115
|
+
}),
|
|
116
|
+
isUnknown ? /*#__PURE__*/ jsxs("div", {
|
|
117
|
+
className: classnames('byline-field-relation-error-tile', relation_field_module["error-tile"]),
|
|
118
|
+
children: [
|
|
119
|
+
/*#__PURE__*/ jsx("span", {
|
|
120
|
+
children: t('fields.relation.unknownError', {
|
|
121
|
+
name: field.name,
|
|
122
|
+
target: field.targetCollection
|
|
123
|
+
})
|
|
124
|
+
}),
|
|
125
|
+
/*#__PURE__*/ jsx("span", {
|
|
126
|
+
className: classnames('byline-field-relation-error-text', relation_field_module["error-text"]),
|
|
127
|
+
children: t('fields.relation.unknownHint')
|
|
128
|
+
})
|
|
129
|
+
]
|
|
130
|
+
}) : /*#__PURE__*/ jsxs(Fragment, {
|
|
131
|
+
children: [
|
|
132
|
+
0 === items.length ? /*#__PURE__*/ jsx("p", {
|
|
133
|
+
className: classnames('byline-field-relation-many-empty', relation_field_module["many-empty"]),
|
|
134
|
+
children: t('fields.relation.manyEmpty', {
|
|
135
|
+
label: targetDef.labels.plural
|
|
136
|
+
})
|
|
137
|
+
}) : /*#__PURE__*/ jsx(DraggableSortable, {
|
|
138
|
+
ids: items.map((v)=>v.targetDocumentId),
|
|
139
|
+
onDragEnd: handleDragEnd,
|
|
140
|
+
className: classnames('byline-field-relation-many', relation_field_module.many),
|
|
141
|
+
children: items.map((value)=>/*#__PURE__*/ jsx(RelationManyTile, {
|
|
142
|
+
id: value.targetDocumentId,
|
|
143
|
+
onRemove: ()=>handleRemove(value.targetDocumentId),
|
|
144
|
+
removeAriaLabel: t('fields.relation.removeAriaLabel', {
|
|
145
|
+
label: targetLabel
|
|
146
|
+
}),
|
|
147
|
+
children: /*#__PURE__*/ jsx(RelationSummary, {
|
|
148
|
+
targetDefinition: targetDef,
|
|
149
|
+
targetAdminConfig: targetAdminConfig,
|
|
150
|
+
displayField: field.displayField,
|
|
151
|
+
value: value,
|
|
152
|
+
cachedRecord: pickedRecords[value.targetDocumentId] ?? null
|
|
153
|
+
})
|
|
154
|
+
}, value.targetDocumentId))
|
|
155
|
+
}),
|
|
156
|
+
fieldError && /*#__PURE__*/ jsx(ErrorText, {
|
|
157
|
+
id: `${field.name}-error`,
|
|
158
|
+
text: fieldError
|
|
159
|
+
}),
|
|
160
|
+
/*#__PURE__*/ jsx("div", {
|
|
161
|
+
className: classnames('byline-field-relation-many-add', relation_field_module["many-add"]),
|
|
162
|
+
children: /*#__PURE__*/ jsxs(Button, {
|
|
163
|
+
id: htmlId,
|
|
164
|
+
size: "xs",
|
|
165
|
+
variant: "outlined",
|
|
166
|
+
intent: "noeffect",
|
|
167
|
+
type: "button",
|
|
168
|
+
onClick: ()=>setPickerOpen(true),
|
|
169
|
+
children: [
|
|
170
|
+
/*#__PURE__*/ jsx(PlusIcon, {
|
|
171
|
+
width: "14px",
|
|
172
|
+
height: "14px"
|
|
173
|
+
}),
|
|
174
|
+
t('fields.relation.addButton', {
|
|
175
|
+
label: targetLabel
|
|
176
|
+
})
|
|
177
|
+
]
|
|
178
|
+
})
|
|
179
|
+
}),
|
|
180
|
+
/*#__PURE__*/ jsx(RelationPicker, {
|
|
181
|
+
targetCollectionPath: field.targetCollection,
|
|
182
|
+
targetDefinition: targetDef,
|
|
183
|
+
displayField: field.displayField,
|
|
184
|
+
isOpen: pickerOpen,
|
|
185
|
+
onSelect: handleAdd,
|
|
186
|
+
onDismiss: ()=>setPickerOpen(false)
|
|
187
|
+
})
|
|
188
|
+
]
|
|
189
|
+
})
|
|
190
|
+
]
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
export { RelationManyField };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useState } from "react";
|
|
3
|
-
import { getCollectionAdminConfig } from "@byline/core";
|
|
3
|
+
import { getCollectionAdminConfig, resolveItemViewColumns } from "@byline/core";
|
|
4
4
|
import { useTranslation } from "@byline/i18n/react";
|
|
5
5
|
import { Button, LoaderRing, Modal, Search } from "@byline/ui/react";
|
|
6
6
|
import classnames from "classnames";
|
|
@@ -20,7 +20,7 @@ const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField,
|
|
|
20
20
|
const [collectionId, setCollectionId] = useState(null);
|
|
21
21
|
const { getCollectionDocuments } = useBylineFieldServices();
|
|
22
22
|
const targetAdminConfig = getCollectionAdminConfig(targetCollectionPath);
|
|
23
|
-
const pickerColumns = targetAdminConfig
|
|
23
|
+
const pickerColumns = resolveItemViewColumns(targetAdminConfig);
|
|
24
24
|
useEffect(()=>{
|
|
25
25
|
if (isOpen) {
|
|
26
26
|
setQuery('');
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { resolveItemViewColumns } from "@byline/core";
|
|
2
3
|
import classnames from "classnames";
|
|
3
4
|
import { PickerCell, resolveFallbackDisplayField, resolveRowLabel } from "./relation-display.js";
|
|
4
5
|
import relation_summary_module from "./relation-summary.module.js";
|
|
5
6
|
function RelationSummary({ targetDefinition, targetAdminConfig, displayField, value, cachedRecord }) {
|
|
6
|
-
const pickerColumns = targetAdminConfig
|
|
7
|
+
const pickerColumns = resolveItemViewColumns(targetAdminConfig);
|
|
7
8
|
if (false === value._resolved) return /*#__PURE__*/ jsxs("div", {
|
|
8
9
|
className: classnames('byline-relation-summary-stack', relation_summary_module.stack),
|
|
9
10
|
children: [
|
package/dist/react.d.ts
CHANGED
|
@@ -45,7 +45,9 @@ export * from './fields/image/image-upload-field.js';
|
|
|
45
45
|
export * from './fields/local-date-time.js';
|
|
46
46
|
export * from './fields/locale-badge.js';
|
|
47
47
|
export * from './fields/numerical/numerical-field.js';
|
|
48
|
+
export * from './fields/relation/relation-column-formatter.js';
|
|
48
49
|
export * from './fields/relation/relation-field.js';
|
|
50
|
+
export * from './fields/relation/relation-many-field.js';
|
|
49
51
|
export * from './fields/relation/relation-picker.js';
|
|
50
52
|
export * from './fields/select/select-field.js';
|
|
51
53
|
export * from './fields/sortable-item.js';
|
package/dist/react.js
CHANGED
|
@@ -16,7 +16,9 @@ export * from "./fields/image/image-upload-field.js";
|
|
|
16
16
|
export * from "./fields/local-date-time.js";
|
|
17
17
|
export * from "./fields/locale-badge.js";
|
|
18
18
|
export * from "./fields/numerical/numerical-field.js";
|
|
19
|
+
export * from "./fields/relation/relation-column-formatter.js";
|
|
19
20
|
export * from "./fields/relation/relation-field.js";
|
|
21
|
+
export * from "./fields/relation/relation-many-field.js";
|
|
20
22
|
export * from "./fields/relation/relation-picker.js";
|
|
21
23
|
export * from "./fields/select/select-field.js";
|
|
22
24
|
export * from "./fields/sortable-item.js";
|
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.15.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -156,10 +156,10 @@
|
|
|
156
156
|
"uuid": "^14.0.0",
|
|
157
157
|
"zod": "^4.4.3",
|
|
158
158
|
"zod-form-data": "^3.0.1",
|
|
159
|
-
"@byline/auth": "3.
|
|
160
|
-
"@byline/core": "3.
|
|
161
|
-
"@byline/i18n": "3.
|
|
162
|
-
"@byline/ui": "3.
|
|
159
|
+
"@byline/auth": "3.15.0",
|
|
160
|
+
"@byline/core": "3.15.0",
|
|
161
|
+
"@byline/i18n": "3.15.0",
|
|
162
|
+
"@byline/ui": "3.15.0"
|
|
163
163
|
},
|
|
164
164
|
"peerDependencies": {
|
|
165
165
|
"react": "^19.0.0",
|
|
@@ -28,6 +28,7 @@ import { ImageField } from './image/image-field'
|
|
|
28
28
|
import { LocaleBadge } from './locale-badge'
|
|
29
29
|
import { NumericalField } from './numerical/numerical-field'
|
|
30
30
|
import { RelationField } from './relation/relation-field'
|
|
31
|
+
import { RelationManyField } from './relation/relation-many-field'
|
|
31
32
|
import { SelectField } from './select/select-field'
|
|
32
33
|
import { TextField } from './text/text-field'
|
|
33
34
|
import { TextAreaField } from './text-area/text-area-field'
|
|
@@ -223,6 +224,16 @@ export const FieldRenderer = ({
|
|
|
223
224
|
/>
|
|
224
225
|
)
|
|
225
226
|
case 'relation':
|
|
227
|
+
if (field.hasMany) {
|
|
228
|
+
return (
|
|
229
|
+
<RelationManyField
|
|
230
|
+
field={hideLabel ? { ...field, label: undefined } : field}
|
|
231
|
+
defaultValue={defaultValue}
|
|
232
|
+
path={path}
|
|
233
|
+
id={htmlId}
|
|
234
|
+
/>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
226
237
|
return (
|
|
227
238
|
<RelationField
|
|
228
239
|
field={hideLabel ? { ...field, label: undefined } : field}
|
|
@@ -0,0 +1,119 @@
|
|
|
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 {
|
|
10
|
+
CollectionDefinition,
|
|
11
|
+
ColumnDefinition,
|
|
12
|
+
ColumnFormatter,
|
|
13
|
+
RelationField,
|
|
14
|
+
} from '@byline/core'
|
|
15
|
+
import { getCollectionDefinition } from '@byline/core'
|
|
16
|
+
|
|
17
|
+
import { resolveFallbackDisplayField, resolveRowLabel } from './relation-display'
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Built-in list-view column formatter for `relation` fields. Renders the
|
|
21
|
+
// target's title (via the displayField → useAsTitle → first-text chain)
|
|
22
|
+
// instead of a raw `target_document_id`. For `hasMany` relations it renders
|
|
23
|
+
// the first few titles followed by "+N more".
|
|
24
|
+
//
|
|
25
|
+
// Requires the list read to populate relation columns (depth 1) — the list
|
|
26
|
+
// server fn does this for relation columns so each cell value arrives as a
|
|
27
|
+
// populated relation envelope (`{ targetDocumentId, _resolved, document }`)
|
|
28
|
+
// or, for hasMany, an array of them.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
interface RelationEnvelope {
|
|
32
|
+
targetDocumentId?: string
|
|
33
|
+
_resolved?: boolean
|
|
34
|
+
document?: Record<string, any>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Max titles shown for a hasMany cell before collapsing to "+N more". */
|
|
38
|
+
const MAX_SHOWN = 3
|
|
39
|
+
|
|
40
|
+
function labelFor(
|
|
41
|
+
env: RelationEnvelope | null | undefined,
|
|
42
|
+
displayField: string | null
|
|
43
|
+
): string | null {
|
|
44
|
+
// Unresolved (deleted / out-of-scope target) or not populated → no title.
|
|
45
|
+
if (env == null || env._resolved === false || env.document == null) return null
|
|
46
|
+
return resolveRowLabel(env.document, displayField)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pure text projection of a relation cell: the target's title for a single
|
|
51
|
+
* relation, or "A, B, +N more" for `hasMany`. Unresolved targets are skipped;
|
|
52
|
+
* an all-empty cell yields `null` (the formatter renders a muted dash).
|
|
53
|
+
*
|
|
54
|
+
* `displayField` is the resolved chain
|
|
55
|
+
* (`field.displayField` → target `useAsTitle` → first text field).
|
|
56
|
+
*/
|
|
57
|
+
export function formatRelationCellText(
|
|
58
|
+
field: Pick<RelationField, 'hasMany'>,
|
|
59
|
+
value: unknown,
|
|
60
|
+
displayField: string | null
|
|
61
|
+
): string | null {
|
|
62
|
+
const envelopes: RelationEnvelope[] = field.hasMany
|
|
63
|
+
? Array.isArray(value)
|
|
64
|
+
? (value as RelationEnvelope[])
|
|
65
|
+
: []
|
|
66
|
+
: value
|
|
67
|
+
? [value as RelationEnvelope]
|
|
68
|
+
: []
|
|
69
|
+
|
|
70
|
+
const labels = envelopes
|
|
71
|
+
.map((env) => labelFor(env, displayField))
|
|
72
|
+
.filter((l): l is string => l != null && l.length > 0)
|
|
73
|
+
|
|
74
|
+
if (labels.length === 0) return null
|
|
75
|
+
if (!field.hasMany) return labels[0] ?? null
|
|
76
|
+
|
|
77
|
+
const shown = labels.slice(0, MAX_SHOWN)
|
|
78
|
+
const extra = labels.length - shown.length
|
|
79
|
+
return extra > 0 ? `${shown.join(', ')}, +${extra} more` : shown.join(', ')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The built-in `{ component }` column formatter for a `relation` field. Resolves
|
|
84
|
+
* the target collection's `useAsTitle` at render time and renders the cell text.
|
|
85
|
+
*/
|
|
86
|
+
export function relationColumnFormatter(field: RelationField): ColumnFormatter {
|
|
87
|
+
return {
|
|
88
|
+
component: ({ value }) => {
|
|
89
|
+
const targetDef = getCollectionDefinition(field.targetCollection)
|
|
90
|
+
const displayField =
|
|
91
|
+
field.displayField ?? targetDef?.useAsTitle ?? resolveFallbackDisplayField(targetDef)
|
|
92
|
+
const text = formatRelationCellText(field, value, displayField)
|
|
93
|
+
return text != null ? (
|
|
94
|
+
<span className="byline-relation-column">{text}</span>
|
|
95
|
+
) : (
|
|
96
|
+
<span className="byline-relation-column-empty muted">—</span>
|
|
97
|
+
)
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Return a copy of `columns` with {@link relationColumnFormatter} applied to any
|
|
104
|
+
* `relation`-typed column that doesn't already declare its own formatter, so
|
|
105
|
+
* relation cells render target titles by default. Other columns are untouched.
|
|
106
|
+
*/
|
|
107
|
+
export function applyRelationColumnFormatters(
|
|
108
|
+
columns: ColumnDefinition[],
|
|
109
|
+
definition: CollectionDefinition
|
|
110
|
+
): ColumnDefinition[] {
|
|
111
|
+
return columns.map((col) => {
|
|
112
|
+
if (col.formatter) return col
|
|
113
|
+
const field = definition.fields.find((f) => f.name === String(col.fieldName))
|
|
114
|
+
if (field?.type === 'relation') {
|
|
115
|
+
return { ...col, formatter: relationColumnFormatter(field as RelationField) }
|
|
116
|
+
}
|
|
117
|
+
return col
|
|
118
|
+
})
|
|
119
|
+
}
|
|
@@ -81,3 +81,84 @@
|
|
|
81
81
|
:global(.byline-field-relation-mono) {
|
|
82
82
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/* ---------------------------------------------------------------------------
|
|
86
|
+
* hasMany — ordered list of relation tiles with drag-reorder + remove.
|
|
87
|
+
*
|
|
88
|
+
* Override handles:
|
|
89
|
+
* .byline-field-relation-many — vertical list of tiles
|
|
90
|
+
* .byline-field-relation-many-empty — empty-state hint
|
|
91
|
+
* .byline-field-relation-many-tile — one row: grip + summary + remove
|
|
92
|
+
* .byline-field-relation-many-grip — drag handle
|
|
93
|
+
* .byline-field-relation-many-body — flexible summary cell
|
|
94
|
+
* .byline-field-relation-many-add — add-item button row
|
|
95
|
+
* --------------------------------------------------------------------------- */
|
|
96
|
+
|
|
97
|
+
.many,
|
|
98
|
+
:global(.byline-field-relation-many) {
|
|
99
|
+
display: flex;
|
|
100
|
+
flex-direction: column;
|
|
101
|
+
gap: var(--spacing-4);
|
|
102
|
+
margin-top: 0.25rem;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.many-empty,
|
|
106
|
+
:global(.byline-field-relation-many-empty) {
|
|
107
|
+
margin: 0.25rem 0;
|
|
108
|
+
color: var(--gray-400);
|
|
109
|
+
font-size: var(--font-size-xs);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.many-tile,
|
|
113
|
+
:global(.byline-field-relation-many-tile) {
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
gap: var(--spacing-8);
|
|
117
|
+
padding: var(--spacing-4) var(--spacing-8);
|
|
118
|
+
border: var(--border-width-thin) var(--border-style-solid) var(--primary-500);
|
|
119
|
+
border-radius: var(--border-radius-md);
|
|
120
|
+
background-color: var(--surface-100, transparent);
|
|
121
|
+
color: var(--gray-200);
|
|
122
|
+
font-size: var(--font-size-xs);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.many-tile-dragging,
|
|
126
|
+
:global(.byline-field-relation-many-tile-dragging) {
|
|
127
|
+
opacity: 0.85;
|
|
128
|
+
box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.2));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.many-grip,
|
|
132
|
+
:global(.byline-field-relation-many-grip) {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
flex-shrink: 0;
|
|
136
|
+
padding: 0;
|
|
137
|
+
border: none;
|
|
138
|
+
background: none;
|
|
139
|
+
color: var(--gray-400);
|
|
140
|
+
cursor: grab;
|
|
141
|
+
touch-action: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.many-grip:active,
|
|
145
|
+
:global(.byline-field-relation-many-grip:active) {
|
|
146
|
+
cursor: grabbing;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.many-body,
|
|
150
|
+
:global(.byline-field-relation-many-body) {
|
|
151
|
+
flex: 1 1 auto;
|
|
152
|
+
min-width: 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.many-remove,
|
|
156
|
+
:global(.byline-field-relation-many-remove) {
|
|
157
|
+
flex-shrink: 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.many-add,
|
|
161
|
+
:global(.byline-field-relation-many-add) {
|
|
162
|
+
display: flex;
|
|
163
|
+
margin-top: 0.25rem;
|
|
164
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
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 ReactNode, useState } from 'react'
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CollectionAdminConfig,
|
|
13
|
+
CollectionDefinition,
|
|
14
|
+
RelationField as FieldType,
|
|
15
|
+
RelatedDocumentValue,
|
|
16
|
+
} from '@byline/core'
|
|
17
|
+
import { getCollectionAdminConfig, getCollectionDefinition } from '@byline/core'
|
|
18
|
+
import { useTranslation } from '@byline/i18n/react'
|
|
19
|
+
import {
|
|
20
|
+
Button,
|
|
21
|
+
CloseIcon,
|
|
22
|
+
DraggableSortable,
|
|
23
|
+
ErrorText,
|
|
24
|
+
GripperVerticalIcon,
|
|
25
|
+
IconButton,
|
|
26
|
+
Label,
|
|
27
|
+
moveItem,
|
|
28
|
+
PlusIcon,
|
|
29
|
+
useSortable,
|
|
30
|
+
} from '@byline/ui/react'
|
|
31
|
+
import cx from 'classnames'
|
|
32
|
+
|
|
33
|
+
import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context'
|
|
34
|
+
import styles from './relation-field.module.css'
|
|
35
|
+
import { RelationPicker } from './relation-picker'
|
|
36
|
+
import { RelationSummary } from './relation-summary'
|
|
37
|
+
|
|
38
|
+
// A single stored item. On the edit path the loader's populate pass attaches
|
|
39
|
+
// `_resolved` / `document`; freshly-picked items are bare refs. Both flatten
|
|
40
|
+
// the same way (storage ignores the envelope keys), so we never strip them.
|
|
41
|
+
type IncomingRelationValue = RelatedDocumentValue & {
|
|
42
|
+
_resolved?: boolean
|
|
43
|
+
_cycle?: boolean
|
|
44
|
+
document?: Record<string, any>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// RelationManyField — `type: 'relation'` with `hasMany: true`.
|
|
49
|
+
//
|
|
50
|
+
// An ordered list of summary tiles: drag to reorder, ✕ to remove, "+ Add" to
|
|
51
|
+
// open the standard picker and append. The value is an array of relation
|
|
52
|
+
// envelopes; every mutation writes the whole array back via `setFieldValue`,
|
|
53
|
+
// which emits a coalesced `field.set` patch — relations carry no stable `_id`,
|
|
54
|
+
// so the granular `array.*` patches (which key on `_id`) don't apply, and a
|
|
55
|
+
// whole-array set is both correct and simplest for these small lists.
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
interface RelationManyTileProps {
|
|
59
|
+
id: string
|
|
60
|
+
children: ReactNode
|
|
61
|
+
onRemove: () => void
|
|
62
|
+
removeAriaLabel: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }: RelationManyTileProps) => {
|
|
66
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
67
|
+
id,
|
|
68
|
+
transition: { duration: 250, easing: 'cubic-bezier(0, 0.2, 0.2, 1)' },
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const style = {
|
|
72
|
+
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
|
73
|
+
transition,
|
|
74
|
+
zIndex: isDragging ? 10 : 'auto',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
ref={setNodeRef}
|
|
80
|
+
style={style}
|
|
81
|
+
className={cx(
|
|
82
|
+
'byline-field-relation-many-tile',
|
|
83
|
+
styles['many-tile'],
|
|
84
|
+
isDragging && ['byline-field-relation-many-tile-dragging', styles['many-tile-dragging']]
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
className={cx('byline-field-relation-many-grip', styles['many-grip'])}
|
|
90
|
+
{...attributes}
|
|
91
|
+
{...listeners}
|
|
92
|
+
>
|
|
93
|
+
<GripperVerticalIcon />
|
|
94
|
+
</button>
|
|
95
|
+
<div className={cx('byline-field-relation-many-body', styles['many-body'])}>{children}</div>
|
|
96
|
+
<IconButton
|
|
97
|
+
type="button"
|
|
98
|
+
size="xs"
|
|
99
|
+
intent="noeffect"
|
|
100
|
+
aria-label={removeAriaLabel}
|
|
101
|
+
className={cx('byline-field-relation-many-remove', styles['many-remove'])}
|
|
102
|
+
onClick={onRemove}
|
|
103
|
+
>
|
|
104
|
+
<CloseIcon width="14px" height="14px" />
|
|
105
|
+
</IconButton>
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface RelationManyFieldProps {
|
|
111
|
+
field: FieldType
|
|
112
|
+
defaultValue?: RelatedDocumentValue[] | null
|
|
113
|
+
id?: string
|
|
114
|
+
path?: string
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const RelationManyField = ({ field, defaultValue, id, path }: RelationManyFieldProps) => {
|
|
118
|
+
const fieldPath = path ?? field.name
|
|
119
|
+
const htmlId = id ?? fieldPath
|
|
120
|
+
const { t } = useTranslation('byline-admin')
|
|
121
|
+
const fieldError = useFieldError(fieldPath)
|
|
122
|
+
const { getFieldValue, setFieldValue } = useFormContext()
|
|
123
|
+
|
|
124
|
+
const targetDef: CollectionDefinition | null = getCollectionDefinition(field.targetCollection)
|
|
125
|
+
const targetAdminConfig: CollectionAdminConfig | null = getCollectionAdminConfig(
|
|
126
|
+
field.targetCollection
|
|
127
|
+
)
|
|
128
|
+
const isUnknown = targetDef == null
|
|
129
|
+
const targetLabel = targetDef?.labels.singular ?? field.targetCollection
|
|
130
|
+
|
|
131
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
132
|
+
// Records handed back by the picker, keyed by target id — lets a freshly
|
|
133
|
+
// added tile render real display data (title / thumbnail) without a refetch.
|
|
134
|
+
const [pickedRecords, setPickedRecords] = useState<Record<string, Record<string, any>>>({})
|
|
135
|
+
|
|
136
|
+
// Subscribe to the live array so reorders / adds / removes re-render.
|
|
137
|
+
const liveValue = useFieldValue<IncomingRelationValue[] | undefined>(fieldPath)
|
|
138
|
+
const items: IncomingRelationValue[] = Array.isArray(liveValue)
|
|
139
|
+
? liveValue
|
|
140
|
+
: Array.isArray(defaultValue)
|
|
141
|
+
? (defaultValue as IncomingRelationValue[])
|
|
142
|
+
: []
|
|
143
|
+
|
|
144
|
+
const currentArray = (): IncomingRelationValue[] => {
|
|
145
|
+
const v = getFieldValue(fieldPath)
|
|
146
|
+
if (Array.isArray(v)) return v as IncomingRelationValue[]
|
|
147
|
+
return Array.isArray(defaultValue) ? (defaultValue as IncomingRelationValue[]) : []
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const handleAdd = (selection: {
|
|
151
|
+
targetDocumentId: string
|
|
152
|
+
targetCollectionId: string
|
|
153
|
+
record?: Record<string, any>
|
|
154
|
+
}) => {
|
|
155
|
+
setPickerOpen(false)
|
|
156
|
+
const current = currentArray()
|
|
157
|
+
// Dedup — a target may appear at most once.
|
|
158
|
+
if (current.some((v) => v.targetDocumentId === selection.targetDocumentId)) return
|
|
159
|
+
if (selection.record) {
|
|
160
|
+
setPickedRecords((prev) => ({ ...prev, [selection.targetDocumentId]: selection.record! }))
|
|
161
|
+
}
|
|
162
|
+
setFieldValue(fieldPath, [
|
|
163
|
+
...current,
|
|
164
|
+
{
|
|
165
|
+
targetDocumentId: selection.targetDocumentId,
|
|
166
|
+
targetCollectionId: selection.targetCollectionId,
|
|
167
|
+
},
|
|
168
|
+
])
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const handleRemove = (targetDocumentId: string) => {
|
|
172
|
+
setFieldValue(
|
|
173
|
+
fieldPath,
|
|
174
|
+
currentArray().filter((v) => v.targetDocumentId !== targetDocumentId)
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handleDragEnd = ({
|
|
179
|
+
moveFromIndex,
|
|
180
|
+
moveToIndex,
|
|
181
|
+
}: {
|
|
182
|
+
moveFromIndex: number
|
|
183
|
+
moveToIndex: number
|
|
184
|
+
}) => {
|
|
185
|
+
if (moveFromIndex === moveToIndex) return
|
|
186
|
+
setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className={`byline-field-relation ${field.name}`}>
|
|
191
|
+
<div className={cx('byline-field-relation-header', styles.header)}>
|
|
192
|
+
<Label
|
|
193
|
+
id={`${htmlId}-label`}
|
|
194
|
+
htmlFor={htmlId}
|
|
195
|
+
label={field.label ?? field.name}
|
|
196
|
+
required={!field.optional}
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
{field.helpText && (
|
|
200
|
+
<div className={cx('byline-field-relation-help', styles.help)}>{field.helpText}</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{isUnknown ? (
|
|
204
|
+
<div className={cx('byline-field-relation-error-tile', styles['error-tile'])}>
|
|
205
|
+
<span>
|
|
206
|
+
{t('fields.relation.unknownError', {
|
|
207
|
+
name: field.name,
|
|
208
|
+
target: field.targetCollection,
|
|
209
|
+
})}
|
|
210
|
+
</span>
|
|
211
|
+
<span className={cx('byline-field-relation-error-text', styles['error-text'])}>
|
|
212
|
+
{t('fields.relation.unknownHint')}
|
|
213
|
+
</span>
|
|
214
|
+
</div>
|
|
215
|
+
) : (
|
|
216
|
+
<>
|
|
217
|
+
{items.length === 0 ? (
|
|
218
|
+
<p className={cx('byline-field-relation-many-empty', styles['many-empty'])}>
|
|
219
|
+
{t('fields.relation.manyEmpty', { label: targetDef.labels.plural })}
|
|
220
|
+
</p>
|
|
221
|
+
) : (
|
|
222
|
+
<DraggableSortable
|
|
223
|
+
ids={items.map((v) => v.targetDocumentId)}
|
|
224
|
+
onDragEnd={handleDragEnd}
|
|
225
|
+
className={cx('byline-field-relation-many', styles.many)}
|
|
226
|
+
>
|
|
227
|
+
{items.map((value) => (
|
|
228
|
+
<RelationManyTile
|
|
229
|
+
key={value.targetDocumentId}
|
|
230
|
+
id={value.targetDocumentId}
|
|
231
|
+
onRemove={() => handleRemove(value.targetDocumentId)}
|
|
232
|
+
removeAriaLabel={t('fields.relation.removeAriaLabel', { label: targetLabel })}
|
|
233
|
+
>
|
|
234
|
+
<RelationSummary
|
|
235
|
+
targetDefinition={targetDef}
|
|
236
|
+
targetAdminConfig={targetAdminConfig}
|
|
237
|
+
displayField={field.displayField}
|
|
238
|
+
value={value}
|
|
239
|
+
cachedRecord={pickedRecords[value.targetDocumentId] ?? null}
|
|
240
|
+
/>
|
|
241
|
+
</RelationManyTile>
|
|
242
|
+
))}
|
|
243
|
+
</DraggableSortable>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{fieldError && <ErrorText id={`${field.name}-error`} text={fieldError} />}
|
|
247
|
+
|
|
248
|
+
<div className={cx('byline-field-relation-many-add', styles['many-add'])}>
|
|
249
|
+
<Button
|
|
250
|
+
id={htmlId}
|
|
251
|
+
size="xs"
|
|
252
|
+
variant="outlined"
|
|
253
|
+
intent="noeffect"
|
|
254
|
+
type="button"
|
|
255
|
+
onClick={() => setPickerOpen(true)}
|
|
256
|
+
>
|
|
257
|
+
<PlusIcon width="14px" height="14px" />
|
|
258
|
+
{t('fields.relation.addButton', { label: targetLabel })}
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<RelationPicker
|
|
263
|
+
targetCollectionPath={field.targetCollection}
|
|
264
|
+
targetDefinition={targetDef}
|
|
265
|
+
displayField={field.displayField}
|
|
266
|
+
isOpen={pickerOpen}
|
|
267
|
+
onSelect={handleAdd}
|
|
268
|
+
onDismiss={() => setPickerOpen(false)}
|
|
269
|
+
/>
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { useCallback, useEffect, useState } from 'react'
|
|
10
10
|
|
|
11
11
|
import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
|
|
12
|
-
import { getCollectionAdminConfig } from '@byline/core'
|
|
12
|
+
import { getCollectionAdminConfig, resolveItemViewColumns } from '@byline/core'
|
|
13
13
|
import { useTranslation } from '@byline/i18n/react'
|
|
14
14
|
import { Button, LoaderRing, Modal, Search } from '@byline/ui/react'
|
|
15
15
|
import cx from 'classnames'
|
|
@@ -102,7 +102,7 @@ export const RelationPicker = ({
|
|
|
102
102
|
|
|
103
103
|
const targetAdminConfig: CollectionAdminConfig | null =
|
|
104
104
|
getCollectionAdminConfig(targetCollectionPath)
|
|
105
|
-
const pickerColumns = targetAdminConfig
|
|
105
|
+
const pickerColumns = resolveItemViewColumns(targetAdminConfig)
|
|
106
106
|
|
|
107
107
|
// Reset local state each time the modal opens so prior queries don't leak.
|
|
108
108
|
useEffect(() => {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
|
|
10
|
+
import { resolveItemViewColumns } from '@byline/core'
|
|
10
11
|
import cx from 'classnames'
|
|
11
12
|
|
|
12
13
|
import { PickerCell, resolveFallbackDisplayField, resolveRowLabel } from './relation-display'
|
|
@@ -17,7 +18,7 @@ import styles from './relation-summary.module.css'
|
|
|
17
18
|
//
|
|
18
19
|
// Rendering priority (mirrors RelationPicker so the tile and picker rows
|
|
19
20
|
// look identical):
|
|
20
|
-
// 1. target `CollectionAdminConfig.
|
|
21
|
+
// 1. target `CollectionAdminConfig.itemView` columns (full fidelity, with
|
|
21
22
|
// formatters — e.g. MediaThumbnail + title)
|
|
22
23
|
// 2. explicit `displayField` prop (from source schema's RelationField)
|
|
23
24
|
// 3. `CollectionDefinition.useAsTitle`
|
|
@@ -62,7 +63,7 @@ export function RelationSummary({
|
|
|
62
63
|
value,
|
|
63
64
|
cachedRecord,
|
|
64
65
|
}: RelationSummaryProps) {
|
|
65
|
-
const pickerColumns = targetAdminConfig
|
|
66
|
+
const pickerColumns = resolveItemViewColumns(targetAdminConfig)
|
|
66
67
|
|
|
67
68
|
// Unresolved (deleted target).
|
|
68
69
|
if (value._resolved === false) {
|
package/src/react.ts
CHANGED
|
@@ -48,7 +48,9 @@ export * from './fields/image/image-upload-field.js'
|
|
|
48
48
|
export * from './fields/local-date-time.js'
|
|
49
49
|
export * from './fields/locale-badge.js'
|
|
50
50
|
export * from './fields/numerical/numerical-field.js'
|
|
51
|
+
export * from './fields/relation/relation-column-formatter.js'
|
|
51
52
|
export * from './fields/relation/relation-field.js'
|
|
53
|
+
export * from './fields/relation/relation-many-field.js'
|
|
52
54
|
export * from './fields/relation/relation-picker.js'
|
|
53
55
|
export * from './fields/select/select-field.js'
|
|
54
56
|
export * from './fields/sortable-item.js'
|