@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.
@@ -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?.picker;
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?.picker;
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.13.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.13.3",
160
- "@byline/core": "3.13.3",
161
- "@byline/i18n": "3.13.3",
162
- "@byline/ui": "3.13.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?.picker
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.picker` columns (full fidelity, with
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?.picker
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'