@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.
- package/dist/modules/analytics/components/dashboard.d.ts +13 -3
- package/dist/modules/analytics/components/dashboard.js +86 -68
- package/dist/modules/analytics/components/dashboard.module.js +10 -1
- package/dist/modules/analytics/components/dashboard_module.css +57 -0
- package/dist/modules/analytics/components/ranked-list-modal.d.ts +32 -0
- package/dist/modules/analytics/components/ranked-list-modal.js +145 -0
- package/dist/modules/analytics/components/ranking.d.ts +45 -0
- package/dist/modules/analytics/components/ranking.js +71 -0
- package/dist/modules/analytics/components/ranking.test.node.d.ts +8 -0
- package/package.json +14 -14
- package/src/modules/analytics/components/dashboard.module.css +69 -0
- package/src/modules/analytics/components/dashboard.tsx +105 -83
- package/src/modules/analytics/components/ranked-list-modal.test.tsx +154 -0
- package/src/modules/analytics/components/ranked-list-modal.tsx +159 -0
- package/src/modules/analytics/components/ranking.test.node.ts +43 -0
- package/src/modules/analytics/components/ranking.tsx +122 -0
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- 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
|
-
}
|