@byline/admin 5.1.2 → 5.1.3

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.
Files changed (78) hide show
  1. package/dist/exports-parity.test.node.js +37 -0
  2. package/dist/fields/array/array-field.jsx +209 -0
  3. package/dist/fields/blocks/blocks-field.jsx +248 -0
  4. package/dist/fields/checkbox/checkbox-field.jsx +24 -0
  5. package/dist/fields/code/code-editor.jsx +211 -0
  6. package/dist/fields/code/code-field.jsx +109 -0
  7. package/dist/fields/column-formatter.jsx +25 -0
  8. package/dist/fields/date-time-formatter.jsx +18 -0
  9. package/dist/fields/datetime/datetime-field.jsx +22 -0
  10. package/dist/fields/draggable-context-menu.jsx +50 -0
  11. package/dist/fields/field-admin.test.node.js +40 -0
  12. package/dist/fields/field-renderer.jsx +136 -0
  13. package/dist/fields/field-services-context.jsx +17 -0
  14. package/dist/fields/file/file-field.jsx +176 -0
  15. package/dist/fields/file/file-upload-field.jsx +89 -0
  16. package/dist/fields/group/group-field.jsx +46 -0
  17. package/dist/fields/image/image-field.jsx +183 -0
  18. package/dist/fields/image/image-upload-field.jsx +157 -0
  19. package/dist/fields/local-date-time.jsx +69 -0
  20. package/dist/fields/locale-badge.jsx +19 -0
  21. package/dist/fields/numerical/numerical-field.jsx +93 -0
  22. package/dist/fields/relation/relation-column-formatter.jsx +74 -0
  23. package/dist/fields/relation/relation-display.jsx +118 -0
  24. package/dist/fields/relation/relation-field.jsx +108 -0
  25. package/dist/fields/relation/relation-many-field.jsx +142 -0
  26. package/dist/fields/relation/relation-picker.jsx +240 -0
  27. package/dist/fields/relation/relation-summary.jsx +45 -0
  28. package/dist/fields/select/select-field.jsx +27 -0
  29. package/dist/fields/sortable-item.jsx +54 -0
  30. package/dist/fields/text/text-field.jsx +79 -0
  31. package/dist/fields/text-area/text-area-field.jsx +79 -0
  32. package/dist/forms/available-locales-reconcile.test.node.js +43 -0
  33. package/dist/forms/available-locales-widget.jsx +63 -0
  34. package/dist/forms/document-actions.jsx +486 -0
  35. package/dist/forms/form-context.jsx +598 -0
  36. package/dist/forms/form-modals.jsx +128 -0
  37. package/dist/forms/form-renderer.jsx +532 -0
  38. package/dist/forms/form-status-display.jsx +69 -0
  39. package/dist/forms/navigation-guard.jsx +68 -0
  40. package/dist/forms/nested-path.test.node.js +115 -0
  41. package/dist/forms/path-widget.jsx +125 -0
  42. package/dist/forms/pending-uploads.test.node.js +19 -0
  43. package/dist/forms/repeating-items.test.node.js +29 -0
  44. package/dist/forms/scheduled-publication-control.jsx +389 -0
  45. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  46. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  47. package/dist/forms/status-transitions.test.node.js +76 -0
  48. package/dist/forms/tree-placement-widget.jsx +160 -0
  49. package/dist/forms/upload-executor.test.node.js +368 -0
  50. package/dist/forms/use-form-layout.test.node.js +69 -0
  51. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  52. package/dist/modules/admin-account/components/container.jsx +164 -0
  53. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  54. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  55. package/dist/modules/admin-account/components/update.jsx +186 -0
  56. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  57. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  58. package/dist/modules/admin-roles/components/create.jsx +155 -0
  59. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  60. package/dist/modules/admin-roles/components/update.jsx +148 -0
  61. package/dist/modules/admin-users/components/create.jsx +206 -0
  62. package/dist/modules/admin-users/components/roles.jsx +126 -0
  63. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  64. package/dist/modules/admin-users/components/update.jsx +215 -0
  65. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  66. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  67. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  68. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  69. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  70. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  71. package/dist/presentation/group.jsx +26 -0
  72. package/dist/presentation/row.jsx +23 -0
  73. package/dist/presentation/tabs.jsx +39 -0
  74. package/dist/services/admin-services-context.jsx +17 -0
  75. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  76. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  77. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  78. package/package.json +7 -7
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ // undefined will use the user's locale
4
+ const dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
5
+ year: 'numeric',
6
+ month: 'short', // <- short month text (locale-aware)
7
+ day: '2-digit',
8
+ hour: 'numeric',
9
+ minute: 'numeric',
10
+ });
11
+ const dateFormatter = new Intl.DateTimeFormat(undefined, {
12
+ year: 'numeric',
13
+ month: 'short', // <- short month text (locale-aware)
14
+ day: '2-digit',
15
+ });
16
+ /**
17
+ * Client-side date/time formatter that displays dates in the user's local timezone.
18
+ *
19
+ * This component avoids hydration mismatches by:
20
+ * 1. Rendering a placeholder on the server/initial render
21
+ * 2. Updating to the correctly formatted date after hydration
22
+ *
23
+ * Use this instead of formatDateTime when you need to display dates
24
+ * in the user's local timezone rather than UTC.
25
+ */
26
+ export function LocalDateTime({ value, mode = 'datetime', className, fallback = '—', }) {
27
+ const [formattedDate, setFormattedDate] = useState(null);
28
+ useEffect(() => {
29
+ if (value == null) {
30
+ setFormattedDate(null);
31
+ return;
32
+ }
33
+ const date = typeof value === 'string' ? Date.parse(value) : value.getTime();
34
+ if (Number.isNaN(date)) {
35
+ setFormattedDate('Error');
36
+ return;
37
+ }
38
+ const formatter = mode === 'datetime' ? dateTimeFormatter : dateFormatter;
39
+ setFormattedDate(formatter.format(date));
40
+ }, [value, mode]);
41
+ // Show fallback during SSR and before useEffect runs
42
+ if (formattedDate === null) {
43
+ return <span className={className}>{value ? '...' : fallback}</span>;
44
+ }
45
+ return <span className={className}>{formattedDate}</span>;
46
+ }
47
+ /**
48
+ * Formats a date string to the user's local timezone.
49
+ *
50
+ * WARNING: Only use this in client components where hydration
51
+ * mismatches are acceptable or managed. For most cases, prefer
52
+ * the <LocalDateTime /> component.
53
+ */
54
+ export function formatLocalDateTime(value) {
55
+ if (value == null)
56
+ return '—';
57
+ const date = typeof value === 'string' ? Date.parse(value) : value.getTime();
58
+ if (Number.isNaN(date))
59
+ return 'Error';
60
+ return dateTimeFormatter.format(date);
61
+ }
62
+ export function formatLocalDate(value) {
63
+ if (value == null)
64
+ return '—';
65
+ const date = typeof value === 'string' ? Date.parse(value) : value.getTime();
66
+ if (Number.isNaN(date))
67
+ return 'Error';
68
+ return dateFormatter.format(date);
69
+ }
@@ -0,0 +1,19 @@
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 cx from 'clsx';
9
+ import styles from './locale-badge.module.css';
10
+ /**
11
+ * Small inline badge shown next to the label of a field that has
12
+ * `localized: true` in its schema definition. Indicates which locale
13
+ * the editor is currently working in.
14
+ *
15
+ * Stable override handle: `.byline-locale-badge`.
16
+ */
17
+ export const LocaleBadge = ({ locale }) => (<span aria-hidden="true" title={`Localised — editing ${locale.toUpperCase()} content`} className={cx('byline-locale-badge', styles.badge)}>
18
+ {locale}
19
+ </span>);
@@ -0,0 +1,93 @@
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 { useEffect, useState } from 'react';
9
+ import { normalizeNumericValue } from '@byline/core';
10
+ import { Input } from '@byline/ui/react';
11
+ import { useFieldError, useFieldValue } from '../../forms/form-context';
12
+ const COMPLETE_NUMERIC_RE = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
13
+ export const NumericalField = ({ field, value, defaultValue, onChange, id, path, components, }) => {
14
+ const fieldPath = path ?? field.name;
15
+ const fieldError = useFieldError(fieldPath);
16
+ const fieldValue = useFieldValue(fieldPath);
17
+ const incomingValue = value !== undefined ? value : fieldValue !== undefined ? fieldValue : (defaultValue ?? null);
18
+ const htmlId = id ?? fieldPath;
19
+ const canonicalDisplay = incomingValue == null ? '' : String(incomingValue);
20
+ const [displayValue, setDisplayValue] = useState(canonicalDisplay);
21
+ const [isEditing, setIsEditing] = useState(false);
22
+ useEffect(() => {
23
+ if (!isEditing)
24
+ setDisplayValue(canonicalDisplay);
25
+ }, [canonicalDisplay, isEditing]);
26
+ const canonicalize = (nextValue) => {
27
+ if (nextValue == null || (typeof nextValue === 'string' && nextValue.trim() === ''))
28
+ return null;
29
+ if (field.type === 'counter')
30
+ return undefined;
31
+ if (typeof nextValue === 'string' && !COMPLETE_NUMERIC_RE.test(nextValue.trim())) {
32
+ return undefined;
33
+ }
34
+ try {
35
+ return normalizeNumericValue(field.type, nextValue, fieldPath);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ };
41
+ const commit = (nextValue) => {
42
+ const canonical = canonicalize(nextValue);
43
+ if (canonical !== undefined)
44
+ onChange?.(canonical);
45
+ return canonical;
46
+ };
47
+ // Custom component slots (from admin config)
48
+ const slots = components;
49
+ const CustomLabel = slots?.Label;
50
+ const CustomHelpText = slots?.HelpText;
51
+ const CustomField = slots?.Field;
52
+ const BeforeField = slots?.beforeField;
53
+ const AfterField = slots?.afterField;
54
+ // Shared props available to every slot component
55
+ const slotBaseProps = {
56
+ field: field,
57
+ path: fieldPath,
58
+ value: incomingValue,
59
+ error: fieldError,
60
+ id: htmlId,
61
+ };
62
+ const hasCustomLabel = !!CustomLabel;
63
+ const suppressInputLabel = hasCustomLabel;
64
+ const suppressInputHelpText = !!CustomHelpText;
65
+ // ── Label rendering ──────────────────────────────────────────
66
+ const renderLabel = () => {
67
+ if (hasCustomLabel) {
68
+ return <CustomLabel {...slotBaseProps} label={field.label} required={!field.optional}/>;
69
+ }
70
+ return null;
71
+ };
72
+ // ── Field input rendering ────────────────────────────────────
73
+ const renderInput = () => {
74
+ if (CustomField) {
75
+ return (<CustomField {...slotBaseProps} onChange={(nextValue) => commit(nextValue)} defaultValue={defaultValue} placeholder={field.placeholder}/>);
76
+ }
77
+ return (<Input type="text" inputMode={field.type === 'integer' || field.type === 'counter' ? 'numeric' : 'decimal'} step={field.type === 'integer' || field.type === 'counter' ? 1 : 'any'} id={htmlId} name={field.name} label={suppressInputLabel ? undefined : field.label} required={!field.optional} readOnly={field.readOnly} helpText={suppressInputHelpText ? undefined : field.helpText} value={displayValue} onFocus={() => setIsEditing(true)} onChange={(e) => {
78
+ setDisplayValue(e.target.value);
79
+ commit(e.target.value);
80
+ }} onBlur={() => {
81
+ setIsEditing(false);
82
+ const canonical = commit(displayValue);
83
+ setDisplayValue(canonical === undefined ? canonicalDisplay : (canonical?.toString() ?? ''));
84
+ }} error={fieldError != null} errorText={fieldError}/>);
85
+ };
86
+ return (<div className={`byline-field-${field.type} ${field.name}`}>
87
+ {renderLabel()}
88
+ {BeforeField && <BeforeField {...slotBaseProps}/>}
89
+ {renderInput()}
90
+ {AfterField && <AfterField {...slotBaseProps}/>}
91
+ {CustomHelpText && <CustomHelpText {...slotBaseProps} helpText={field.helpText}/>}
92
+ </div>);
93
+ };
@@ -0,0 +1,74 @@
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 { getCollectionDefinition } from '@byline/core';
9
+ import { resolveFallbackDisplayField, resolveRowLabel } from './relation-display';
10
+ /** Max titles shown for a hasMany cell before collapsing to "+N more". */
11
+ const MAX_SHOWN = 3;
12
+ function labelFor(env, displayField) {
13
+ // Unresolved (deleted / out-of-scope target) or not populated → no title.
14
+ if (env == null || env._resolved === false || env.document == null)
15
+ return null;
16
+ return resolveRowLabel(env.document, displayField);
17
+ }
18
+ /**
19
+ * Pure text projection of a relation cell: the target's title for a single
20
+ * relation, or "A, B, +N more" for `hasMany`. Unresolved targets are skipped;
21
+ * an all-empty cell yields `null` (the formatter renders a muted dash).
22
+ *
23
+ * `displayField` is the resolved chain
24
+ * (`field.displayField` → target `useAsTitle` → first text field).
25
+ */
26
+ export function formatRelationCellText(field, value, displayField) {
27
+ const envelopes = field.hasMany
28
+ ? Array.isArray(value)
29
+ ? value
30
+ : []
31
+ : value
32
+ ? [value]
33
+ : [];
34
+ const labels = envelopes
35
+ .map((env) => labelFor(env, displayField))
36
+ .filter((l) => l != null && l.length > 0);
37
+ if (labels.length === 0)
38
+ return null;
39
+ if (!field.hasMany)
40
+ return labels[0] ?? null;
41
+ const shown = labels.slice(0, MAX_SHOWN);
42
+ const extra = labels.length - shown.length;
43
+ return extra > 0 ? `${shown.join(', ')}, +${extra} more` : shown.join(', ');
44
+ }
45
+ /**
46
+ * The built-in `{ component }` column formatter for a `relation` field. Resolves
47
+ * the target collection's `useAsTitle` at render time and renders the cell text.
48
+ */
49
+ export function relationColumnFormatter(field) {
50
+ return {
51
+ component: ({ value }) => {
52
+ const targetDef = getCollectionDefinition(field.targetCollection);
53
+ const displayField = field.displayField ?? targetDef?.useAsTitle ?? resolveFallbackDisplayField(targetDef);
54
+ const text = formatRelationCellText(field, value, displayField);
55
+ return text != null ? (<span className="byline-relation-column">{text}</span>) : (<span className="byline-relation-column-empty muted">—</span>);
56
+ },
57
+ };
58
+ }
59
+ /**
60
+ * Return a copy of `columns` with {@link relationColumnFormatter} applied to any
61
+ * `relation`-typed column that doesn't already declare its own formatter, so
62
+ * relation cells render target titles by default. Other columns are untouched.
63
+ */
64
+ export function applyRelationColumnFormatters(columns, definition) {
65
+ return columns.map((col) => {
66
+ if (col.formatter)
67
+ return col;
68
+ const field = definition.fields.find((f) => f.name === String(col.fieldName));
69
+ if (field?.type === 'relation') {
70
+ return { ...col, formatter: relationColumnFormatter(field) };
71
+ }
72
+ return col;
73
+ });
74
+ }
@@ -0,0 +1,118 @@
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 cx from 'clsx';
9
+ import styles from './relation-display.module.css';
10
+ // ---------------------------------------------------------------------------
11
+ // Shared render helpers used by both the relation picker modal rows and
12
+ // the relation-summary tile on the edit form. Kept in lock-step so a row
13
+ // rendered in the picker looks identical to the selected tile.
14
+ // ---------------------------------------------------------------------------
15
+ /**
16
+ * Render a single row cell from a `ColumnDefinition`, reading the value
17
+ * from the document's `fields` (with a fallback to top-level metadata like
18
+ * `status`, `updated_at`, `path`). Honours both formatter shapes — plain
19
+ * function → its return value, `{ component }` → the component is rendered.
20
+ */
21
+ export function PickerCell({ column, record, }) {
22
+ const name = String(column.fieldName);
23
+ const value = record?.fields?.[name] ?? record?.[name];
24
+ let content;
25
+ if (column.formatter) {
26
+ if (typeof column.formatter === 'function') {
27
+ content = column.formatter(value, record);
28
+ }
29
+ else {
30
+ const Comp = column.formatter.component;
31
+ content = <Comp value={value} record={record}/>;
32
+ }
33
+ }
34
+ else if (value == null) {
35
+ content = null;
36
+ }
37
+ else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
38
+ content = String(value);
39
+ }
40
+ else {
41
+ content = null;
42
+ }
43
+ return (<div className={cx('byline-relation-cell', styles.cell, column.align === 'center' && ['byline-relation-cell-center', styles['cell-center']], column.align === 'right' && ['byline-relation-cell-right', styles['cell-right']], column.className)}>
44
+ {content}
45
+ </div>);
46
+ }
47
+ /** First top-level `text` field name on a collection, or null. */
48
+ export function resolveFallbackDisplayField(def) {
49
+ if (!def)
50
+ return null;
51
+ const textField = def.fields.find((f) => f.type === 'text');
52
+ return textField?.name ?? null;
53
+ }
54
+ /** Resolve the row's primary label text from the document. */
55
+ export function resolveRowLabel(doc, displayField) {
56
+ if (!doc)
57
+ return null;
58
+ if (displayField) {
59
+ const v = doc.fields?.[displayField];
60
+ if (typeof v === 'string' && v.length > 0)
61
+ return v;
62
+ }
63
+ if (typeof doc.fields?.title === 'string' && doc.fields.title.length > 0) {
64
+ return doc.fields.title;
65
+ }
66
+ if (typeof doc.path === 'string' && doc.path.length > 0)
67
+ return doc.path;
68
+ return null;
69
+ }
70
+ /**
71
+ * Build the `fields` projection for the picker listing. Unions:
72
+ * - caller-supplied `displayField`
73
+ * - target schema's `useAsTitle` (always included, even when not visible)
74
+ * - every `fieldName` declared in the admin config's `picker` columns
75
+ * - `title` (metadata fallback for rows with no explicit picker columns)
76
+ *
77
+ * Note that the document `path` is top-level metadata on every list response
78
+ * — it is always returned by `getCollectionDocuments` regardless of the
79
+ * `fields` projection. Callers that need `{ title, path }` for downstream
80
+ * normalisation (e.g. the rich-text link plugin) can rely on:
81
+ * - `record.fields[def.useAsTitle]` for the title
82
+ * - `record.path` for the path
83
+ *
84
+ * Returns `undefined` when no target definition is available, leaving the
85
+ * listing endpoint to decide its own default projection.
86
+ */
87
+ export function resolveSelectFields(def, displayField, pickerColumns, extraFields) {
88
+ if (!def)
89
+ return undefined;
90
+ const out = new Set();
91
+ if (displayField)
92
+ out.add(displayField);
93
+ if (def.useAsTitle)
94
+ out.add(def.useAsTitle);
95
+ const fallback = resolveFallbackDisplayField(def);
96
+ if (fallback)
97
+ out.add(fallback);
98
+ if (pickerColumns) {
99
+ for (const col of pickerColumns) {
100
+ const name = String(col.fieldName);
101
+ if (def.fields.some((f) => f.name === name))
102
+ out.add(name);
103
+ }
104
+ }
105
+ // Extra non-display fields a consumer needs on `record.fields` (e.g. the
106
+ // inline-image modal seeding alt-text). Loaded but not rendered as columns.
107
+ if (extraFields) {
108
+ for (const name of extraFields) {
109
+ if (def.fields.some((f) => f.name === name))
110
+ out.add(name);
111
+ }
112
+ }
113
+ if (def.fields.some((f) => f.name === 'title'))
114
+ out.add('title');
115
+ if (out.size === 0)
116
+ return undefined;
117
+ return Array.from(out);
118
+ }
@@ -0,0 +1,108 @@
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 { useState } from 'react';
9
+ import { getCollectionAdminConfig, getCollectionDefinition, isSingleton } from '@byline/core';
10
+ import { useTranslation } from '@byline/i18n/react';
11
+ import { Button, CloseIcon, EditIcon, ErrorText, HelpText, IconButton, Label, } from '@byline/ui/react';
12
+ import cx from 'clsx';
13
+ import { useFieldError, useFieldValue } from '../../forms/form-context';
14
+ import styles from './relation-field.module.css';
15
+ import { RelationPicker } from './relation-picker';
16
+ import { RelationSummary } from './relation-summary';
17
+ export const RelationField = ({ field, value, defaultValue, onChange, id, path, }) => {
18
+ const fieldPath = path ?? field.name;
19
+ const htmlId = id ?? fieldPath;
20
+ const fieldError = useFieldError(fieldPath);
21
+ const fieldValue = useFieldValue(fieldPath);
22
+ const incomingValue = fieldValue !== undefined
23
+ ? (fieldValue ?? null)
24
+ : (value ??
25
+ defaultValue ??
26
+ null);
27
+ // Resolve the target collection definition + admin config. The admin
28
+ // config drives the picker-column rendering inside RelationSummary so
29
+ // the selected tile matches the picker row exactly. Missing target →
30
+ // render an inline error and disable the picker.
31
+ const targetDefinition = getCollectionDefinition(field.targetCollection);
32
+ const targetDef = targetDefinition != null && !isSingleton(targetDefinition) ? targetDefinition : null;
33
+ const targetAdminConfig = getCollectionAdminConfig(field.targetCollection);
34
+ const { t } = useTranslation('byline-admin');
35
+ const [pickerOpen, setPickerOpen] = useState(false);
36
+ // Cached target document from the most recent picker selection. Lets the
37
+ // tile render real display data (name, thumbnail) immediately after a
38
+ // pick without a round trip. Cleared via the `targetDocumentId`
39
+ // comparison in the render path.
40
+ const [pickedRecord, setPickedRecord] = useState(null);
41
+ const handleSelect = (selection) => {
42
+ setPickerOpen(false);
43
+ if (selection.record) {
44
+ setPickedRecord({ id: selection.targetDocumentId, record: selection.record });
45
+ }
46
+ else {
47
+ setPickedRecord(null);
48
+ }
49
+ onChange?.({
50
+ targetDocumentId: selection.targetDocumentId,
51
+ targetCollectionId: selection.targetCollectionId,
52
+ });
53
+ };
54
+ const handleRemove = () => {
55
+ setPickedRecord(null);
56
+ onChange?.(null);
57
+ };
58
+ // Only carry the cached picker record through to the summary when it
59
+ // still matches the current value — guards against a stale cache after
60
+ // an external value change (e.g. patch rollback).
61
+ const cachedRecord = pickedRecord && incomingValue && pickedRecord.id === incomingValue.targetDocumentId
62
+ ? pickedRecord.record
63
+ : null;
64
+ const isUnknown = targetDef == null;
65
+ return (<div className={`byline-field-relation ${field.name}`}>
66
+ <div className={cx('byline-field-relation-header', styles.header)}>
67
+ <Label id={`${htmlId}-label`} htmlFor={htmlId} label={field.label ?? field.name} required={!field.optional}/>
68
+ </div>
69
+ {isUnknown ? (<div className={cx('byline-field-relation-error-tile', styles['error-tile'])}>
70
+ <span>
71
+ {t('fields.relation.unknownError', {
72
+ name: field.name,
73
+ target: field.targetCollection,
74
+ })}
75
+ </span>
76
+ <span className={cx('byline-field-relation-error-text', styles['error-text'])}>
77
+ {t('fields.relation.unknownHint')}
78
+ </span>
79
+ </div>) : (
80
+ // Rounded frame below the label wrapping the interactive body
81
+ // (selected tile or select button) — see `.frame` in the CSS module.
82
+ <div className={cx('byline-field-relation-frame', styles.frame)}>
83
+ {incomingValue ? (<div className={cx('byline-field-relation-tile', styles.tile)}>
84
+ <RelationSummary targetDefinition={targetDef} targetAdminConfig={targetAdminConfig} displayField={field.displayField} value={incomingValue} cachedRecord={cachedRecord}/>
85
+ <div className={cx('byline-field-relation-actions', styles.actions)}>
86
+ <IconButton id={htmlId} type="button" intent="noeffect" size="xs" aria-label={t('fields.relation.changeAriaLabel', {
87
+ label: targetDef.labels.singular,
88
+ })} onClick={() => setPickerOpen(true)}>
89
+ <EditIcon width="15px" height="15px"/>
90
+ </IconButton>
91
+ <IconButton type="button" intent="noeffect" size="xs" aria-label={t('fields.relation.removeAriaLabel', {
92
+ label: targetDef.labels.singular,
93
+ })} onClick={handleRemove}>
94
+ <CloseIcon width="15px" height="15px"/>
95
+ </IconButton>
96
+ </div>
97
+ </div>) : (<Button id={htmlId} size="xs" variant="outlined" intent="noeffect" type="button" onClick={() => setPickerOpen(true)}>
98
+ {t('fields.relation.selectButton', { label: targetDef.labels.singular })}
99
+ </Button>)}
100
+ </div>)}
101
+
102
+ {field.helpText != null && <HelpText text={field.helpText}/>}
103
+
104
+ {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
105
+
106
+ {!isUnknown && (<RelationPicker targetCollectionPath={field.targetCollection} targetDefinition={targetDef} displayField={field.displayField} isOpen={pickerOpen} onSelect={handleSelect} onDismiss={() => setPickerOpen(false)}/>)}
107
+ </div>);
108
+ };
@@ -0,0 +1,142 @@
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 { useState } from 'react';
9
+ import { getCollectionAdminConfig, getCollectionDefinition, isSingleton } from '@byline/core';
10
+ import { useTranslation } from '@byline/i18n/react';
11
+ import { Button, CloseIcon, DraggableSortable, ErrorText, GripperVerticalIcon, HelpText, IconButton, Label, moveItem, PlusIcon, useSortable, } from '@byline/ui/react';
12
+ import cx from 'clsx';
13
+ import { useFieldError, useFieldValue, useFormContext } from '../../forms/form-context';
14
+ import styles from './relation-field.module.css';
15
+ import { RelationPicker } from './relation-picker';
16
+ import { RelationSummary } from './relation-summary';
17
+ const RelationManyTile = ({ id, children, onRemove, removeAriaLabel }) => {
18
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
19
+ id,
20
+ transition: { duration: 250, easing: 'cubic-bezier(0, 0.2, 0.2, 1)' },
21
+ });
22
+ const style = {
23
+ transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
24
+ transition,
25
+ zIndex: isDragging ? 10 : 'auto',
26
+ };
27
+ return (<div ref={setNodeRef} style={style} className={cx('byline-field-relation-many-tile', styles['many-tile'], isDragging && ['byline-field-relation-many-tile-dragging', styles['many-tile-dragging']])}>
28
+ <button type="button" className={cx('byline-field-relation-many-grip', styles['many-grip'])} {...attributes} {...listeners}>
29
+ <GripperVerticalIcon />
30
+ </button>
31
+ <div className={cx('byline-field-relation-many-body', styles['many-body'])}>{children}</div>
32
+ <IconButton type="button" size="xs" intent="noeffect" aria-label={removeAriaLabel} className={cx('byline-field-relation-many-remove', styles['many-remove'])} onClick={onRemove}>
33
+ <CloseIcon width="14px" height="14px"/>
34
+ </IconButton>
35
+ </div>);
36
+ };
37
+ export const RelationManyField = ({ field, defaultValue, id, path }) => {
38
+ const fieldPath = path ?? field.name;
39
+ const htmlId = id ?? fieldPath;
40
+ const { t } = useTranslation('byline-admin');
41
+ const fieldError = useFieldError(fieldPath);
42
+ const { getFieldValue, setFieldValue } = useFormContext();
43
+ const targetDefinition = getCollectionDefinition(field.targetCollection);
44
+ const targetDef = targetDefinition != null && !isSingleton(targetDefinition) ? targetDefinition : null;
45
+ const targetAdminConfig = getCollectionAdminConfig(field.targetCollection);
46
+ const isUnknown = targetDef == null;
47
+ const targetLabel = targetDef?.labels.singular ?? field.targetCollection;
48
+ const [pickerOpen, setPickerOpen] = useState(false);
49
+ // Records handed back by the picker, keyed by target id — lets a freshly
50
+ // added tile render real display data (title / thumbnail) without a refetch.
51
+ const [pickedRecords, setPickedRecords] = useState({});
52
+ // Subscribe to the live array so reorders / adds / removes re-render.
53
+ const liveValue = useFieldValue(fieldPath);
54
+ const items = Array.isArray(liveValue)
55
+ ? liveValue
56
+ : Array.isArray(defaultValue)
57
+ ? defaultValue
58
+ : [];
59
+ const currentArray = () => {
60
+ const v = getFieldValue(fieldPath);
61
+ if (Array.isArray(v))
62
+ return v;
63
+ return Array.isArray(defaultValue) ? defaultValue : [];
64
+ };
65
+ const handleAddMany = (selections) => {
66
+ setPickerOpen(false);
67
+ const current = currentArray();
68
+ // Dedup the batch against the current array — a target may appear at most
69
+ // once. The picker already disables already-added rows, so this is a
70
+ // belt-and-braces guard for stale state (e.g. a concurrent remove).
71
+ const existing = new Set(current.map((v) => v.targetDocumentId));
72
+ const additions = selections.filter((s) => !existing.has(s.targetDocumentId));
73
+ if (additions.length === 0)
74
+ return;
75
+ const records = additions.filter((s) => s.record != null);
76
+ if (records.length > 0) {
77
+ setPickedRecords((prev) => {
78
+ const next = { ...prev };
79
+ for (const s of records)
80
+ next[s.targetDocumentId] = s.record;
81
+ return next;
82
+ });
83
+ }
84
+ setFieldValue(fieldPath, [
85
+ ...current,
86
+ ...additions.map((s) => ({
87
+ targetDocumentId: s.targetDocumentId,
88
+ targetCollectionId: s.targetCollectionId,
89
+ })),
90
+ ]);
91
+ };
92
+ const handleRemove = (targetDocumentId) => {
93
+ setFieldValue(fieldPath, currentArray().filter((v) => v.targetDocumentId !== targetDocumentId));
94
+ };
95
+ const handleDragEnd = ({ moveFromIndex, moveToIndex, }) => {
96
+ if (moveFromIndex === moveToIndex)
97
+ return;
98
+ setFieldValue(fieldPath, moveItem(currentArray(), moveFromIndex, moveToIndex));
99
+ };
100
+ return (<div className={`byline-field-relation ${field.name}`}>
101
+ <div className={cx('byline-field-relation-header', styles.header)}>
102
+ <Label id={`${htmlId}-label`} htmlFor={htmlId} label={field.label ?? field.name} required={!field.optional}/>
103
+ </div>
104
+ {isUnknown ? (<div className={cx('byline-field-relation-error-tile', styles['error-tile'])}>
105
+ <span>
106
+ {t('fields.relation.unknownError', {
107
+ name: field.name,
108
+ target: field.targetCollection,
109
+ })}
110
+ </span>
111
+ <span className={cx('byline-field-relation-error-text', styles['error-text'])}>
112
+ {t('fields.relation.unknownHint')}
113
+ </span>
114
+ </div>) : (<>
115
+ {/* Rounded frame below the label wrapping the interactive
116
+ body (tile list and add button) — see `.frame` in the CSS
117
+ module. */}
118
+ <div className={cx('byline-field-relation-frame', styles.frame)}>
119
+ {items.length === 0 ? (<p className={cx('byline-field-relation-many-empty', styles['many-empty'])}>
120
+ {t('fields.relation.manyEmpty', { label: targetDef.labels.plural })}
121
+ </p>) : (<DraggableSortable ids={items.map((v) => v.targetDocumentId)} onDragEnd={handleDragEnd} className={cx('byline-field-relation-many', styles.many)}>
122
+ {items.map((value) => (<RelationManyTile key={value.targetDocumentId} id={value.targetDocumentId} onRemove={() => handleRemove(value.targetDocumentId)} removeAriaLabel={t('fields.relation.removeAriaLabel', { label: targetLabel })}>
123
+ <RelationSummary targetDefinition={targetDef} targetAdminConfig={targetAdminConfig} displayField={field.displayField} value={value} cachedRecord={pickedRecords[value.targetDocumentId] ?? null}/>
124
+ </RelationManyTile>))}
125
+ </DraggableSortable>)}
126
+
127
+ <div className={cx('byline-field-relation-many-add', styles['many-add'])}>
128
+ <Button id={htmlId} size="xs" variant="outlined" intent="noeffect" type="button" onClick={() => setPickerOpen(true)}>
129
+ <PlusIcon width="14px" height="14px"/>
130
+ {t('fields.relation.addButton', { label: targetLabel })}
131
+ </Button>
132
+ </div>
133
+ </div>
134
+
135
+ {field.helpText != null && <HelpText text={field.helpText}/>}
136
+
137
+ {fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
138
+
139
+ <RelationPicker multiple targetCollectionPath={field.targetCollection} targetDefinition={targetDef} displayField={field.displayField} isOpen={pickerOpen} excludeIds={items.map((v) => v.targetDocumentId)} onSelectMany={handleAddMany} onDismiss={() => setPickerOpen(false)}/>
140
+ </>)}
141
+ </div>);
142
+ };