@byline/admin 5.1.3 → 5.2.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.
Files changed (93) hide show
  1. package/dist/modules/analytics/components/dashboard.d.ts +13 -3
  2. package/dist/modules/analytics/components/dashboard.js +86 -68
  3. package/dist/modules/analytics/components/dashboard.module.js +10 -1
  4. package/dist/modules/analytics/components/dashboard_module.css +57 -0
  5. package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
  6. package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
  7. package/dist/modules/analytics/components/ranking.d.ts +45 -0
  8. package/dist/modules/analytics/components/ranking.js +71 -0
  9. package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
  10. package/package.json +14 -14
  11. package/src/modules/analytics/components/dashboard.module.css +69 -0
  12. package/src/modules/analytics/components/dashboard.tsx +105 -83
  13. package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
  14. package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
  15. package/src/modules/analytics/components/ranking.test.node.ts +43 -0
  16. package/src/modules/analytics/components/ranking.tsx +122 -0
  17. package/dist/exports-parity.test.node.js +0 -37
  18. package/dist/fields/array/array-field.jsx +0 -209
  19. package/dist/fields/blocks/blocks-field.jsx +0 -248
  20. package/dist/fields/checkbox/checkbox-field.jsx +0 -24
  21. package/dist/fields/code/code-editor.jsx +0 -211
  22. package/dist/fields/code/code-field.jsx +0 -109
  23. package/dist/fields/column-formatter.jsx +0 -25
  24. package/dist/fields/date-time-formatter.jsx +0 -18
  25. package/dist/fields/datetime/datetime-field.jsx +0 -22
  26. package/dist/fields/draggable-context-menu.jsx +0 -50
  27. package/dist/fields/field-admin.test.node.js +0 -40
  28. package/dist/fields/field-renderer.jsx +0 -136
  29. package/dist/fields/field-services-context.jsx +0 -17
  30. package/dist/fields/file/file-field.jsx +0 -176
  31. package/dist/fields/file/file-upload-field.jsx +0 -89
  32. package/dist/fields/group/group-field.jsx +0 -46
  33. package/dist/fields/image/image-field.jsx +0 -183
  34. package/dist/fields/image/image-upload-field.jsx +0 -157
  35. package/dist/fields/local-date-time.jsx +0 -69
  36. package/dist/fields/locale-badge.jsx +0 -19
  37. package/dist/fields/numerical/numerical-field.jsx +0 -93
  38. package/dist/fields/relation/relation-column-formatter.jsx +0 -74
  39. package/dist/fields/relation/relation-display.jsx +0 -118
  40. package/dist/fields/relation/relation-field.jsx +0 -108
  41. package/dist/fields/relation/relation-many-field.jsx +0 -142
  42. package/dist/fields/relation/relation-picker.jsx +0 -240
  43. package/dist/fields/relation/relation-summary.jsx +0 -45
  44. package/dist/fields/select/select-field.jsx +0 -27
  45. package/dist/fields/sortable-item.jsx +0 -54
  46. package/dist/fields/text/text-field.jsx +0 -79
  47. package/dist/fields/text-area/text-area-field.jsx +0 -79
  48. package/dist/forms/available-locales-reconcile.test.node.js +0 -43
  49. package/dist/forms/available-locales-widget.jsx +0 -63
  50. package/dist/forms/document-actions.jsx +0 -486
  51. package/dist/forms/form-context.jsx +0 -598
  52. package/dist/forms/form-modals.jsx +0 -128
  53. package/dist/forms/form-renderer.jsx +0 -532
  54. package/dist/forms/form-status-display.jsx +0 -69
  55. package/dist/forms/navigation-guard.jsx +0 -68
  56. package/dist/forms/nested-path.test.node.js +0 -115
  57. package/dist/forms/path-widget.jsx +0 -125
  58. package/dist/forms/pending-uploads.test.node.js +0 -19
  59. package/dist/forms/repeating-items.test.node.js +0 -29
  60. package/dist/forms/scheduled-publication-control.jsx +0 -389
  61. package/dist/forms/scheduled-publication-state.test.node.js +0 -121
  62. package/dist/forms/scheduled-publication-time.test.node.js +0 -70
  63. package/dist/forms/status-transitions.test.node.js +0 -76
  64. package/dist/forms/tree-placement-widget.jsx +0 -160
  65. package/dist/forms/upload-executor.test.node.js +0 -368
  66. package/dist/forms/use-form-layout.test.node.js +0 -69
  67. package/dist/modules/admin-account/components/change-password.jsx +0 -169
  68. package/dist/modules/admin-account/components/container.jsx +0 -164
  69. package/dist/modules/admin-account/components/preferences.jsx +0 -148
  70. package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
  71. package/dist/modules/admin-account/components/update.jsx +0 -186
  72. package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
  73. package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
  74. package/dist/modules/admin-roles/components/create.jsx +0 -155
  75. package/dist/modules/admin-roles/components/permissions.jsx +0 -239
  76. package/dist/modules/admin-roles/components/update.jsx +0 -148
  77. package/dist/modules/admin-users/components/create.jsx +0 -206
  78. package/dist/modules/admin-users/components/roles.jsx +0 -126
  79. package/dist/modules/admin-users/components/set-password.jsx +0 -133
  80. package/dist/modules/admin-users/components/update.jsx +0 -215
  81. package/dist/modules/analytics/components/dashboard.jsx +0 -208
  82. package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
  83. package/dist/modules/analytics/components/timeseries.jsx +0 -193
  84. package/dist/modules/auth/components/sign-in-form.jsx +0 -89
  85. package/dist/modules/auth/safe-redirect.test.node.js +0 -40
  86. package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
  87. package/dist/presentation/group.jsx +0 -26
  88. package/dist/presentation/row.jsx +0 -23
  89. package/dist/presentation/tabs.jsx +0 -39
  90. package/dist/services/admin-services-context.jsx +0 -17
  91. package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
  92. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
  93. package/dist/widgets/status-badge/status-badge.jsx +0 -46
@@ -1,118 +0,0 @@
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
- }
@@ -1,108 +0,0 @@
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
- };
@@ -1,142 +0,0 @@
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
- };
@@ -1,240 +0,0 @@
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 { useCallback, useEffect, useState } from 'react';
9
- import { getCollectionAdminConfig } from '@byline/core';
10
- import { useTranslation } from '@byline/i18n/react';
11
- import { Button, CheckIcon, LoaderRing, Modal, Search } from '@byline/ui/react';
12
- import cx from 'clsx';
13
- import { useBylineFieldServices } from '../field-services-context';
14
- import { PickerCell, resolveFallbackDisplayField, resolveRowLabel, resolveSelectFields, } from './relation-display';
15
- import styles from './relation-picker.module.css';
16
- const PAGE_SIZE = 15;
17
- export const RelationPicker = ({ targetCollectionPath, targetDefinition, displayField, extraSelectFields, isOpen, multiple = false, excludeIds, onSelect, onSelectMany, onDismiss, }) => {
18
- const [query, setQuery] = useState('');
19
- const [page, setPage] = useState(1);
20
- const { t } = useTranslation('byline-admin');
21
- const [selectedDocumentId, setSelectedDocumentId] = useState(null);
22
- // Multi-select state. A Map keyed by target id preserves pick order (the
23
- // confirmed batch appends in that order) and carries each row's record so
24
- // picks survive page/search changes that swap out `documents`.
25
- const [selectedMap, setSelectedMap] = useState(() => new Map());
26
- const [loading, setLoading] = useState(false);
27
- const [error, setError] = useState(null);
28
- const [documents, setDocuments] = useState([]);
29
- const [totalPages, setTotalPages] = useState(1);
30
- const [collectionId, setCollectionId] = useState(null);
31
- const { getCollectionDocuments } = useBylineFieldServices();
32
- const targetAdminConfig = getCollectionAdminConfig(targetCollectionPath);
33
- const pickerColumns = targetAdminConfig?.itemView;
34
- // Reset local state each time the modal opens so prior queries don't leak.
35
- useEffect(() => {
36
- if (isOpen) {
37
- setQuery('');
38
- setPage(1);
39
- setSelectedDocumentId(null);
40
- setSelectedMap(new Map());
41
- setError(null);
42
- }
43
- }, [isOpen]);
44
- // Fetch whenever the modal is open and the query / page changes.
45
- useEffect(() => {
46
- if (!isOpen)
47
- return;
48
- let cancelled = false;
49
- const selectFields = resolveSelectFields(targetDefinition, displayField, pickerColumns, extraSelectFields);
50
- setLoading(true);
51
- setError(null);
52
- // Item-view sort: the target collection's `itemViewSort` (boot-validated)
53
- // orders the picker independently of its list view's `defaultSort`.
54
- // Passed as explicit params because the list server fn gives an explicit
55
- // `order` top precedence; when absent the server falls back through
56
- // `defaultSort` → `created_at desc` (or `order_key asc` for orderable
57
- // collections) exactly as before.
58
- const itemViewSort = targetAdminConfig?.itemViewSort;
59
- getCollectionDocuments({
60
- collection: targetCollectionPath,
61
- params: {
62
- page,
63
- page_size: PAGE_SIZE,
64
- query: query.length > 0 ? query : undefined,
65
- fields: selectFields,
66
- ...(itemViewSort != null
67
- ? { order: String(itemViewSort.field), desc: itemViewSort.direction === 'desc' }
68
- : {}),
69
- },
70
- })
71
- .then((response) => {
72
- if (cancelled)
73
- return;
74
- setDocuments(response.docs);
75
- setTotalPages(response.meta.totalPages ?? 1);
76
- setCollectionId(response.included.collection.id);
77
- })
78
- .catch((err) => {
79
- if (cancelled)
80
- return;
81
- setError(err instanceof Error ? err.message : t('fields.relation.picker.loadFailed'));
82
- })
83
- .finally(() => {
84
- if (!cancelled)
85
- setLoading(false);
86
- });
87
- return () => {
88
- cancelled = true;
89
- };
90
- }, [
91
- isOpen,
92
- targetCollectionPath,
93
- query,
94
- page,
95
- displayField,
96
- extraSelectFields,
97
- targetDefinition,
98
- pickerColumns,
99
- getCollectionDocuments,
100
- t,
101
- targetAdminConfig?.itemViewSort,
102
- ]);
103
- const resolvedDisplayField = displayField ??
104
- targetDefinition?.useAsTitle ??
105
- resolveFallbackDisplayField(targetDefinition) ??
106
- null;
107
- const handleSelect = useCallback(() => {
108
- if (!selectedDocumentId || !collectionId || !onSelect)
109
- return;
110
- const record = documents.find((d) => d?.id === selectedDocumentId);
111
- onSelect({
112
- targetDocumentId: selectedDocumentId,
113
- targetCollectionId: collectionId,
114
- record,
115
- });
116
- }, [selectedDocumentId, collectionId, documents, onSelect]);
117
- const handleSelectMany = useCallback(() => {
118
- if (selectedMap.size === 0 || !collectionId || !onSelectMany)
119
- return;
120
- onSelectMany(Array.from(selectedMap, ([targetDocumentId, record]) => ({
121
- targetDocumentId,
122
- targetCollectionId: collectionId,
123
- record,
124
- })));
125
- }, [selectedMap, collectionId, onSelectMany]);
126
- const toggleSelected = useCallback((doc) => {
127
- const id = doc.id;
128
- setSelectedMap((prev) => {
129
- const next = new Map(prev);
130
- if (next.has(id)) {
131
- next.delete(id);
132
- }
133
- else {
134
- next.set(id, doc);
135
- }
136
- return next;
137
- });
138
- }, []);
139
- const title = t('fields.relation.selectPickerTitle', {
140
- label: targetDefinition?.labels.singular ?? targetCollectionPath,
141
- });
142
- return (<Modal isOpen={isOpen} onDismiss={onDismiss}>
143
- <Modal.Container style={{ maxWidth: '600px', width: '100%' }}>
144
- <Modal.Header className={cx('byline-field-relation-picker-header', styles.header)}>
145
- <h3 className={cx('byline-field-relation-picker-title', styles.title)}>{title}</h3>
146
- </Modal.Header>
147
- <Modal.Content>
148
- <div className={cx('byline-field-relation-picker-body', styles.body)}>
149
- <Search onSearch={(q) => {
150
- setPage(1);
151
- setQuery(q ?? '');
152
- }} onClear={() => {
153
- setPage(1);
154
- setQuery('');
155
- }} inputSize="sm" placeholder={t('fields.relation.picker.searchPlaceholder')}/>
156
-
157
- <div className={cx('byline-field-relation-picker-list', styles.list)}>
158
- {loading && documents.length === 0 && (<div className={cx('byline-field-relation-picker-loading', styles.loading)}>
159
- <LoaderRing size={24} color="#888888"/>
160
- </div>)}
161
- {!loading && error && (<div className={cx('byline-field-relation-picker-error', styles.error)}>
162
- {error}
163
- </div>)}
164
- {!loading && !error && documents.length === 0 && (<div className={cx('byline-field-relation-picker-empty', styles.empty)}>
165
- {t('fields.relation.picker.empty')}
166
- </div>)}
167
- {documents.length > 0 && (<ul className={cx('byline-field-relation-picker-rows', styles.rows)}>
168
- {documents.map((doc) => {
169
- const id = doc.id;
170
- const selected = multiple ? selectedMap.has(id) : selectedDocumentId === id;
171
- const excluded = multiple && (excludeIds?.includes(id) ?? false);
172
- return (<li key={id}>
173
- <button type="button" disabled={excluded} aria-pressed={multiple ? selected : undefined} title={excluded ? t('fields.relation.picker.alreadyAdded') : undefined} className={cx('byline-field-relation-picker-row-button', styles['row-button'], multiple && [
174
- 'byline-field-relation-picker-row-multi',
175
- styles['row-multi'],
176
- ], selected && [
177
- 'byline-field-relation-picker-row-selected',
178
- styles['row-selected'],
179
- ], excluded && [
180
- 'byline-field-relation-picker-row-added',
181
- styles['row-added'],
182
- ])} onClick={() => {
183
- if (excluded)
184
- return;
185
- if (multiple) {
186
- toggleSelected(doc);
187
- }
188
- else {
189
- setSelectedDocumentId(id);
190
- }
191
- }}>
192
- {multiple && (<span aria-hidden="true" className={cx('byline-field-relation-picker-check', styles.check, (selected || excluded) && [
193
- 'byline-field-relation-picker-check-checked',
194
- styles['check-checked'],
195
- ])}>
196
- {(selected || excluded) && <CheckIcon width="12px" height="12px"/>}
197
- </span>)}
198
- {pickerColumns && pickerColumns.length > 0 ? (<div className={cx('byline-field-relation-picker-row-cells', styles['row-cells'])}>
199
- {pickerColumns.map((col) => (<PickerCell key={String(col.fieldName)} column={col} record={doc}/>))}
200
- </div>) : (<div className={cx('byline-field-relation-picker-row-stack', styles['row-stack'])}>
201
- <span className={cx('byline-field-relation-picker-row-label', styles['row-label'])}>
202
- {resolveRowLabel(doc, resolvedDisplayField) || id}
203
- </span>
204
- {typeof doc.path === 'string' && doc.path.length > 0 && (<span className={cx('byline-field-relation-picker-row-path', styles['row-path'])}>
205
- {doc.path}
206
- </span>)}
207
- </div>)}
208
- {excluded && (<span className={cx('byline-field-relation-picker-added-hint', styles['added-hint'])}>
209
- {t('fields.relation.picker.alreadyAdded')}
210
- </span>)}
211
- </button>
212
- </li>);
213
- })}
214
- </ul>)}
215
- </div>
216
-
217
- {totalPages > 1 && (<div className={cx('byline-field-relation-picker-pager', styles.pager)}>
218
- <Button size="xs" variant="outlined" intent="noeffect" type="button" disabled={page <= 1 || loading} onClick={() => setPage((p) => Math.max(1, p - 1))}>
219
- {t('common.pager.previous')}
220
- </Button>
221
- <span>{t('common.pager.pageOf', { page, total: totalPages })}</span>
222
- <Button size="xs" variant="outlined" intent="noeffect" type="button" disabled={page >= totalPages || loading} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>
223
- {t('common.pager.next')}
224
- </Button>
225
- </div>)}
226
- </div>
227
- </Modal.Content>
228
- <Modal.Actions>
229
- <Button size="sm" intent="noeffect" type="button" onClick={onDismiss} className={cx('byline-field-relation-picker-action', styles.action)}>
230
- {t('common.actions.cancel')}
231
- </Button>
232
- <Button size="sm" className={cx('byline-field-relation-picker-action', styles.action)} intent="primary" type="button" disabled={multiple ? selectedMap.size === 0 : !selectedDocumentId} onClick={multiple ? handleSelectMany : handleSelect}>
233
- {multiple
234
- ? t('fields.relation.picker.addSelected', { count: selectedMap.size })
235
- : t('common.actions.select')}
236
- </Button>
237
- </Modal.Actions>
238
- </Modal.Container>
239
- </Modal>);
240
- };
@@ -1,45 +0,0 @@
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 { PickerCell, resolveFallbackDisplayField, resolveRowLabel } from './relation-display';
10
- import styles from './relation-summary.module.css';
11
- export function RelationSummary({ targetDefinition, targetAdminConfig, displayField, value, cachedRecord, }) {
12
- const pickerColumns = targetAdminConfig?.itemView;
13
- // Unresolved (deleted target).
14
- if (value._resolved === false) {
15
- return (<div className={cx('byline-relation-summary-stack', styles.stack)}>
16
- <span className={cx('byline-relation-summary-kind', styles.kind)}>
17
- {targetDefinition.labels.singular}
18
- </span>
19
- <span className={cx('byline-relation-summary-value-mono byline-relation-summary-missing', styles['value-mono'], styles.missing)}>
20
- (target not found) {value.targetDocumentId}
21
- </span>
22
- </div>);
23
- }
24
- // Prefer the populated envelope's document; fall back to the cached
25
- // picker record; finally fall back to rendering just the raw ref.
26
- const record = (value._resolved === true && !value._cycle && value.document) || cachedRecord || null;
27
- if (record && pickerColumns && pickerColumns.length > 0) {
28
- return (<div className={cx('byline-relation-summary-row', styles.row)}>
29
- {pickerColumns.map((col) => (<PickerCell key={String(col.fieldName)} column={col} record={record}/>))}
30
- </div>);
31
- }
32
- const resolvedDisplayField = displayField ??
33
- targetDefinition.useAsTitle ??
34
- resolveFallbackDisplayField(targetDefinition) ??
35
- null;
36
- const label = record ? resolveRowLabel(record, resolvedDisplayField) : null;
37
- return (<div className={cx('byline-relation-summary-stack', styles.stack)}>
38
- <span className={cx('byline-relation-summary-kind', styles.kind)}>
39
- {targetDefinition.labels.singular}
40
- </span>
41
- {label ? (<span className={cx('byline-relation-summary-value', styles.value)}>{label}</span>) : (<span className={cx('byline-relation-summary-value-mono', styles['value-mono'])}>
42
- {value.targetDocumentId}
43
- </span>)}
44
- </div>);
45
- }