@byline/admin 5.1.1 → 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.
- package/dist/exports-parity.test.node.js +37 -0
- package/dist/fields/array/array-field.jsx +209 -0
- package/dist/fields/blocks/blocks-field.jsx +248 -0
- package/dist/fields/checkbox/checkbox-field.jsx +24 -0
- package/dist/fields/code/code-editor.jsx +211 -0
- package/dist/fields/code/code-field.jsx +109 -0
- package/dist/fields/column-formatter.jsx +25 -0
- package/dist/fields/date-time-formatter.jsx +18 -0
- package/dist/fields/datetime/datetime-field.jsx +22 -0
- package/dist/fields/draggable-context-menu.jsx +50 -0
- package/dist/fields/field-admin.test.node.js +40 -0
- package/dist/fields/field-renderer.jsx +136 -0
- package/dist/fields/field-services-context.jsx +17 -0
- package/dist/fields/file/file-field.jsx +176 -0
- package/dist/fields/file/file-upload-field.jsx +89 -0
- package/dist/fields/group/group-field.jsx +46 -0
- package/dist/fields/image/image-field.jsx +183 -0
- package/dist/fields/image/image-upload-field.jsx +157 -0
- package/dist/fields/local-date-time.jsx +69 -0
- package/dist/fields/locale-badge.jsx +19 -0
- package/dist/fields/numerical/numerical-field.jsx +93 -0
- package/dist/fields/relation/relation-column-formatter.jsx +74 -0
- package/dist/fields/relation/relation-display.jsx +118 -0
- package/dist/fields/relation/relation-field.jsx +108 -0
- package/dist/fields/relation/relation-many-field.jsx +142 -0
- package/dist/fields/relation/relation-picker.jsx +240 -0
- package/dist/fields/relation/relation-summary.jsx +45 -0
- package/dist/fields/select/select-field.jsx +27 -0
- package/dist/fields/sortable-item.jsx +54 -0
- package/dist/fields/text/text-field.jsx +79 -0
- package/dist/fields/text-area/text-area-field.jsx +79 -0
- package/dist/forms/available-locales-reconcile.test.node.js +43 -0
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-renderer.jsx +532 -0
- package/dist/forms/form-status-display.jsx +69 -0
- package/dist/forms/navigation-guard.jsx +68 -0
- package/dist/forms/nested-path.test.node.js +115 -0
- package/dist/forms/path-widget.jsx +125 -0
- package/dist/forms/pending-uploads.test.node.js +19 -0
- package/dist/forms/repeating-items.test.node.js +29 -0
- package/dist/forms/scheduled-publication-control.jsx +389 -0
- package/dist/forms/scheduled-publication-state.test.node.js +121 -0
- package/dist/forms/scheduled-publication-time.test.node.js +70 -0
- package/dist/forms/status-transitions.test.node.js +76 -0
- package/dist/forms/tree-placement-widget.jsx +160 -0
- package/dist/forms/upload-executor.test.node.js +368 -0
- package/dist/forms/use-form-layout.test.node.js +69 -0
- package/dist/modules/admin-account/components/change-password.jsx +169 -0
- package/dist/modules/admin-account/components/container.jsx +164 -0
- package/dist/modules/admin-account/components/preferences.jsx +148 -0
- package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
- package/dist/modules/admin-account/components/update.jsx +186 -0
- package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
- package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
- package/dist/modules/admin-roles/components/create.jsx +155 -0
- package/dist/modules/admin-roles/components/permissions.jsx +239 -0
- package/dist/modules/admin-roles/components/update.jsx +148 -0
- package/dist/modules/admin-users/components/create.jsx +206 -0
- package/dist/modules/admin-users/components/roles.jsx +126 -0
- package/dist/modules/admin-users/components/set-password.jsx +133 -0
- package/dist/modules/admin-users/components/update.jsx +215 -0
- package/dist/modules/analytics/components/dashboard.jsx +208 -0
- package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
- package/dist/modules/analytics/components/timeseries.jsx +193 -0
- package/dist/modules/auth/components/sign-in-form.jsx +89 -0
- package/dist/modules/auth/safe-redirect.test.node.js +40 -0
- package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
- package/dist/presentation/group.jsx +26 -0
- package/dist/presentation/row.jsx +23 -0
- package/dist/presentation/tabs.jsx +39 -0
- package/dist/services/admin-services-context.jsx +17 -0
- package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
- package/dist/widgets/status-badge/status-badge.jsx +46 -0
- package/package.json +7 -7
|
@@ -0,0 +1,240 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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 { ErrorText, Label, Select } from '@byline/ui/react';
|
|
9
|
+
import cx from 'clsx';
|
|
10
|
+
import { useFieldError, useFieldValue, useIsDirty } from '../../forms/form-context';
|
|
11
|
+
import styles from './select-field.module.css';
|
|
12
|
+
export const SelectField = ({ field, value, defaultValue, onChange, id, path, }) => {
|
|
13
|
+
const fieldPath = path ?? field.name;
|
|
14
|
+
const fieldError = useFieldError(fieldPath);
|
|
15
|
+
const isDirty = useIsDirty(fieldPath);
|
|
16
|
+
const fieldValue = useFieldValue(fieldPath);
|
|
17
|
+
const incomingValue = value ?? fieldValue ?? defaultValue ?? '';
|
|
18
|
+
const htmlId = id ?? fieldPath;
|
|
19
|
+
return (<div className={`byline-field-select ${field.name}`}>
|
|
20
|
+
{field.label && (<Label id={htmlId} htmlFor={htmlId} label={field.label} required={!field.optional} className={cx('byline-field-select-label', styles.label)}/>)}
|
|
21
|
+
<Select size="xs" variant="outlined" id={htmlId} name={field.name} placeholder="Select an option" required={!field.optional} value={incomingValue} ariaLabel={field.label} helpText={field.helpText} items={field.options.map((opt) => ({ value: opt.value, label: opt.label }))} onValueChange={(value) => {
|
|
22
|
+
if (value != null)
|
|
23
|
+
onChange?.(value);
|
|
24
|
+
}} className={cx(isDirty && ['byline-field-select-dirty', styles.dirty])}/>
|
|
25
|
+
{fieldError && <ErrorText id={`${field.name}-error`} text={fieldError}/>}
|
|
26
|
+
</div>);
|
|
27
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { useTranslation } from '@byline/i18n/react';
|
|
10
|
+
import { ChevronDownIcon, GripperVerticalIcon, useSortable } from '@byline/ui/react';
|
|
11
|
+
import cx from 'clsx';
|
|
12
|
+
import { DraggableContextMenu } from './draggable-context-menu';
|
|
13
|
+
import styles from './sortable-item.module.css';
|
|
14
|
+
const ItemFrame = ({ label, children, onAddBelow, onRemove, grip, rootRef, style, dragging = false, rootClassName, }) => {
|
|
15
|
+
const { t } = useTranslation('byline-admin');
|
|
16
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
17
|
+
return (<div ref={rootRef} style={style} className={cx('byline-sortable', styles.root, rootClassName, dragging && ['byline-sortable-dragging', styles.dragging], collapsed && ['byline-sortable-collapsed', styles.collapsed])}>
|
|
18
|
+
<div className={cx('byline-sortable-header', styles.header, !collapsed && ['byline-sortable-header-expanded', styles['header-expanded']])}>
|
|
19
|
+
{grip}
|
|
20
|
+
<div className={cx('byline-sortable-label', styles.label)}>{label}</div>
|
|
21
|
+
<DraggableContextMenu onAddBelow={onAddBelow} onRemove={onRemove}/>
|
|
22
|
+
<button type="button" className={cx('byline-sortable-toggle', styles.toggle)} onClick={() => setCollapsed((prev) => !prev)} aria-label={collapsed
|
|
23
|
+
? t('fields.sortable.expandAriaLabel')
|
|
24
|
+
: t('fields.sortable.collapseAriaLabel')}>
|
|
25
|
+
<ChevronDownIcon className={cx('byline-sortable-toggle-icon', styles['toggle-icon'], collapsed && ['byline-sortable-toggle-icon-rotated', styles['toggle-icon-rotated']])}/>
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
<div className={cx('byline-sortable-content', styles.content, collapsed && ['byline-sortable-content-hidden', styles['content-hidden']])}>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
31
|
+
</div>);
|
|
32
|
+
};
|
|
33
|
+
export const SortableItem = ({ id, label, children, onAddBelow, onRemove, }) => {
|
|
34
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
35
|
+
id,
|
|
36
|
+
transition: {
|
|
37
|
+
duration: 250,
|
|
38
|
+
easing: 'cubic-bezier(0, 0.2, 0.2, 1)',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
const style = {
|
|
42
|
+
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
|
43
|
+
transition,
|
|
44
|
+
zIndex: isDragging ? 10 : 'auto',
|
|
45
|
+
};
|
|
46
|
+
return (<ItemFrame label={label} onAddBelow={onAddBelow} onRemove={onRemove} rootRef={setNodeRef} style={style} dragging={isDragging} grip={<button type="button" className={cx('byline-sortable-grip', styles.grip)} {...attributes} {...listeners}>
|
|
47
|
+
<GripperVerticalIcon className={cx('byline-sortable-grip-icon', styles['grip-icon'])}/>
|
|
48
|
+
</button>}>
|
|
49
|
+
{children}
|
|
50
|
+
</ItemFrame>);
|
|
51
|
+
};
|
|
52
|
+
export const StaticItem = ({ label, children, onAddBelow, onRemove, }) => (<ItemFrame label={label} onAddBelow={onAddBelow} onRemove={onRemove} rootClassName="byline-sortable-static">
|
|
53
|
+
{children}
|
|
54
|
+
</ItemFrame>);
|
|
@@ -0,0 +1,79 @@
|
|
|
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 } from 'react';
|
|
9
|
+
import { Input, Label } from '@byline/ui/react';
|
|
10
|
+
import cx from 'clsx';
|
|
11
|
+
import { useFieldError, useFieldValue } from '../../forms/form-context';
|
|
12
|
+
import { LocaleBadge } from '../locale-badge';
|
|
13
|
+
import styles from './text-field.module.css';
|
|
14
|
+
export const TextField = ({ field, value, defaultValue, onChange, id, path, locale, components, }) => {
|
|
15
|
+
const fieldPath = path ?? field.name;
|
|
16
|
+
const fieldError = useFieldError(fieldPath);
|
|
17
|
+
const fieldValue = useFieldValue(fieldPath);
|
|
18
|
+
const incomingValue = value ?? fieldValue ?? defaultValue ?? '';
|
|
19
|
+
const htmlId = id ?? fieldPath;
|
|
20
|
+
const handleChange = useCallback((value) => {
|
|
21
|
+
if (onChange) {
|
|
22
|
+
onChange(value);
|
|
23
|
+
}
|
|
24
|
+
}, [onChange]);
|
|
25
|
+
// Custom component slots (from admin config)
|
|
26
|
+
const slots = components;
|
|
27
|
+
const CustomLabel = slots?.Label;
|
|
28
|
+
const CustomHelpText = slots?.HelpText;
|
|
29
|
+
const CustomField = slots?.Field;
|
|
30
|
+
const BeforeField = slots?.beforeField;
|
|
31
|
+
const AfterField = slots?.afterField;
|
|
32
|
+
// Shared props available to every slot component
|
|
33
|
+
const slotBaseProps = {
|
|
34
|
+
field: field,
|
|
35
|
+
path: fieldPath,
|
|
36
|
+
value: incomingValue,
|
|
37
|
+
error: fieldError,
|
|
38
|
+
id: htmlId,
|
|
39
|
+
};
|
|
40
|
+
// When a locale is active, render a custom Label+badge and suppress the
|
|
41
|
+
// Input's own label so the locale indicator appears in the label row.
|
|
42
|
+
const showBadge = !!locale && !!field.label;
|
|
43
|
+
// Determine whether the label is handled externally (by a custom slot or
|
|
44
|
+
// the locale badge row) so Input doesn't render its own.
|
|
45
|
+
const hasCustomLabel = !!CustomLabel;
|
|
46
|
+
const suppressInputLabel = showBadge || hasCustomLabel;
|
|
47
|
+
const suppressInputHelpText = !!CustomHelpText;
|
|
48
|
+
const labelRowClass = cx('byline-field-text-label-row', styles['label-row']);
|
|
49
|
+
// ── Label rendering ──────────────────────────────────────────
|
|
50
|
+
const renderLabel = () => {
|
|
51
|
+
if (hasCustomLabel) {
|
|
52
|
+
return (<div className={labelRowClass}>
|
|
53
|
+
<CustomLabel {...slotBaseProps} label={field.label} required={!field.optional}/>
|
|
54
|
+
{showBadge && <LocaleBadge locale={locale}/>}
|
|
55
|
+
</div>);
|
|
56
|
+
}
|
|
57
|
+
if (showBadge) {
|
|
58
|
+
return (<div className={labelRowClass}>
|
|
59
|
+
<Label id={`${htmlId}-label`} htmlFor={htmlId} label={field.label} required={!field.optional}/>
|
|
60
|
+
<LocaleBadge locale={locale}/>
|
|
61
|
+
</div>);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
// ── Field input rendering ────────────────────────────────────
|
|
66
|
+
const renderInput = () => {
|
|
67
|
+
if (CustomField) {
|
|
68
|
+
return (<CustomField {...slotBaseProps} onChange={handleChange} defaultValue={defaultValue} placeholder={field.placeholder}/>);
|
|
69
|
+
}
|
|
70
|
+
return (<Input id={htmlId} name={field.name} label={suppressInputLabel ? undefined : field.label} required={!field.optional} helpText={suppressInputHelpText ? undefined : field.helpText} value={incomingValue} onChange={(e) => handleChange(e.target.value)} error={fieldError != null} errorText={fieldError}/>);
|
|
71
|
+
};
|
|
72
|
+
return (<div className={`byline-field-text ${field.name}`}>
|
|
73
|
+
{renderLabel()}
|
|
74
|
+
{BeforeField && <BeforeField {...slotBaseProps}/>}
|
|
75
|
+
{renderInput()}
|
|
76
|
+
{AfterField && <AfterField {...slotBaseProps}/>}
|
|
77
|
+
{CustomHelpText && <CustomHelpText {...slotBaseProps} helpText={field.helpText}/>}
|
|
78
|
+
</div>);
|
|
79
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
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 } from 'react';
|
|
9
|
+
import { Label, TextArea } from '@byline/ui/react';
|
|
10
|
+
import cx from 'clsx';
|
|
11
|
+
import { useFieldError, useFieldValue } from '../../forms/form-context';
|
|
12
|
+
import { LocaleBadge } from '../locale-badge';
|
|
13
|
+
import styles from './text-area-field.module.css';
|
|
14
|
+
export const TextAreaField = ({ field, value, defaultValue, onChange, id, path, locale, components, }) => {
|
|
15
|
+
const fieldPath = path ?? field.name;
|
|
16
|
+
const fieldError = useFieldError(fieldPath);
|
|
17
|
+
const fieldValue = useFieldValue(fieldPath);
|
|
18
|
+
const incomingValue = value ?? fieldValue ?? defaultValue ?? '';
|
|
19
|
+
const htmlId = id ?? fieldPath;
|
|
20
|
+
const handleChange = useCallback((value) => {
|
|
21
|
+
if (onChange) {
|
|
22
|
+
onChange(value);
|
|
23
|
+
}
|
|
24
|
+
}, [onChange]);
|
|
25
|
+
// Custom component slots (from admin config)
|
|
26
|
+
const slots = components;
|
|
27
|
+
const CustomLabel = slots?.Label;
|
|
28
|
+
const CustomHelpText = slots?.HelpText;
|
|
29
|
+
const CustomField = slots?.Field;
|
|
30
|
+
const BeforeField = slots?.beforeField;
|
|
31
|
+
const AfterField = slots?.afterField;
|
|
32
|
+
// Shared props available to every slot component
|
|
33
|
+
const slotBaseProps = {
|
|
34
|
+
field: field,
|
|
35
|
+
path: fieldPath,
|
|
36
|
+
value: incomingValue,
|
|
37
|
+
error: fieldError,
|
|
38
|
+
id: htmlId,
|
|
39
|
+
};
|
|
40
|
+
// When a locale is active, render a custom Label+badge and suppress the
|
|
41
|
+
// TextArea's own label so the locale indicator appears in the label row.
|
|
42
|
+
const showBadge = !!locale && !!field.label;
|
|
43
|
+
// Determine whether the label is handled externally (by a custom slot or
|
|
44
|
+
// the locale badge row) so TextArea doesn't render its own.
|
|
45
|
+
const hasCustomLabel = !!CustomLabel;
|
|
46
|
+
const suppressInputLabel = showBadge || hasCustomLabel;
|
|
47
|
+
const suppressInputHelpText = !!CustomHelpText;
|
|
48
|
+
const labelRowClass = cx('byline-field-text-area-label-row', styles['label-row']);
|
|
49
|
+
// ── Label rendering ──────────────────────────────────────────
|
|
50
|
+
const renderLabel = () => {
|
|
51
|
+
if (hasCustomLabel) {
|
|
52
|
+
return (<div className={labelRowClass}>
|
|
53
|
+
<CustomLabel {...slotBaseProps} label={field.label} required={!field.optional}/>
|
|
54
|
+
{showBadge && <LocaleBadge locale={locale}/>}
|
|
55
|
+
</div>);
|
|
56
|
+
}
|
|
57
|
+
if (showBadge) {
|
|
58
|
+
return (<div className={labelRowClass}>
|
|
59
|
+
<Label id={`${htmlId}-label`} htmlFor={htmlId} label={field.label} required={!field.optional}/>
|
|
60
|
+
<LocaleBadge locale={locale}/>
|
|
61
|
+
</div>);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
// ── Field input rendering ────────────────────────────────────
|
|
66
|
+
const renderInput = () => {
|
|
67
|
+
if (CustomField) {
|
|
68
|
+
return (<CustomField {...slotBaseProps} onChange={handleChange} defaultValue={defaultValue} placeholder={field.placeholder}/>);
|
|
69
|
+
}
|
|
70
|
+
return (<TextArea id={htmlId} name={field.name} label={suppressInputLabel ? undefined : field.label} required={!field.optional} helpText={suppressInputHelpText ? undefined : field.helpText} value={incomingValue} onChange={(e) => handleChange(e.target.value)} error={fieldError != null} errorText={fieldError} rows={4}/>);
|
|
71
|
+
};
|
|
72
|
+
return (<div className={`byline-field-text-area ${field.name}`}>
|
|
73
|
+
{renderLabel()}
|
|
74
|
+
{BeforeField && <BeforeField {...slotBaseProps}/>}
|
|
75
|
+
{renderInput()}
|
|
76
|
+
{AfterField && <AfterField {...slotBaseProps}/>}
|
|
77
|
+
{CustomHelpText && <CustomHelpText {...slotBaseProps} helpText={field.helpText}/>}
|
|
78
|
+
</div>);
|
|
79
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
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 { describe, expect, it } from 'vitest';
|
|
9
|
+
import { reconcileLocaleState } from './available-locales-reconcile.js';
|
|
10
|
+
/**
|
|
11
|
+
* The widget's reconciliation is expressed purely as Checkbox intent +
|
|
12
|
+
* disabled state — no per-row text. `reconcileLocaleState` is the pure heart
|
|
13
|
+
* of that mapping; the four cells below are the full truth table from
|
|
14
|
+
* docs/08-internationalization/index.md.
|
|
15
|
+
*/
|
|
16
|
+
describe('reconcileLocaleState', () => {
|
|
17
|
+
it('ledger-complete + advertised → green, enabled (advertised & complete)', () => {
|
|
18
|
+
expect(reconcileLocaleState(true, true)).toEqual({ intent: 'success', disabled: false });
|
|
19
|
+
});
|
|
20
|
+
it('ledger-complete + not advertised → green, enabled (invitation to advertise)', () => {
|
|
21
|
+
expect(reconcileLocaleState(false, true)).toEqual({ intent: 'success', disabled: false });
|
|
22
|
+
});
|
|
23
|
+
it('not complete + advertised → amber, enabled (advertising an incomplete locale)', () => {
|
|
24
|
+
// The ⚠ row: surfaced as `warning` and kept enabled so the editor can
|
|
25
|
+
// uncheck to resolve the over-advertisement.
|
|
26
|
+
expect(reconcileLocaleState(true, false)).toEqual({ intent: 'warning', disabled: false });
|
|
27
|
+
});
|
|
28
|
+
it('not complete + not advertised → gray, disabled (nothing to advertise)', () => {
|
|
29
|
+
expect(reconcileLocaleState(false, false)).toEqual({ intent: 'noeffect', disabled: true });
|
|
30
|
+
});
|
|
31
|
+
it('completeness drives green regardless of advertised state', () => {
|
|
32
|
+
// The ledger fact wins the colour when complete; only the disabled/amber
|
|
33
|
+
// distinction depends on the advertised flag.
|
|
34
|
+
expect(reconcileLocaleState(true, true).intent).toBe('success');
|
|
35
|
+
expect(reconcileLocaleState(false, true).intent).toBe('success');
|
|
36
|
+
});
|
|
37
|
+
it('only the not-complete + not-advertised cell is non-interactive', () => {
|
|
38
|
+
expect(reconcileLocaleState(true, true).disabled).toBe(false);
|
|
39
|
+
expect(reconcileLocaleState(false, true).disabled).toBe(false);
|
|
40
|
+
expect(reconcileLocaleState(true, false).disabled).toBe(false);
|
|
41
|
+
expect(reconcileLocaleState(false, false).disabled).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
6
|
+
*
|
|
7
|
+
* Copyright (c) Infonomic Company Limited
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useMemo } from 'react';
|
|
10
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
11
|
+
import { Checkbox, Label } from '@byline/ui/react';
|
|
12
|
+
import cx from 'clsx';
|
|
13
|
+
import { reconcileLocaleState } from './available-locales-reconcile';
|
|
14
|
+
import styles from './available-locales-widget.module.css';
|
|
15
|
+
import { useFormContext, useSystemAvailableLocales } from './form-context';
|
|
16
|
+
export { reconcileLocaleState } from './available-locales-reconcile';
|
|
17
|
+
/**
|
|
18
|
+
* System-managed `availableLocales` widget — the editorial "advertise these
|
|
19
|
+
* content locales" control. Renders one checkbox per content locale in the
|
|
20
|
+
* sidebar, directly below the path widget. The checked state reflects the
|
|
21
|
+
* stored advertised set (`useSystemAvailableLocales`); each row's intent
|
|
22
|
+
* reconciles that against the structural ledger fact (`availableVersionLocales`)
|
|
23
|
+
* via {@link reconcileLocaleState}. Opt-in: nothing is advertised until the
|
|
24
|
+
* editor checks a (green) locale.
|
|
25
|
+
*
|
|
26
|
+
* Stable override handles: `.byline-form-available-locales`,
|
|
27
|
+
* `.byline-form-available-locales-list`.
|
|
28
|
+
*/
|
|
29
|
+
export const AvailableLocalesWidget = ({ disabled: mutationsBlocked = false, contentLocales, availableVersionLocales, }) => {
|
|
30
|
+
const { t } = useTranslation('byline-admin');
|
|
31
|
+
const { setSystemAvailableLocales } = useFormContext();
|
|
32
|
+
const advertised = useSystemAvailableLocales();
|
|
33
|
+
const advertisedSet = useMemo(() => new Set(advertised), [advertised]);
|
|
34
|
+
const ledgerSet = useMemo(() => new Set(availableVersionLocales), [availableVersionLocales]);
|
|
35
|
+
const toggle = useCallback((code, checked) => {
|
|
36
|
+
if (mutationsBlocked)
|
|
37
|
+
return;
|
|
38
|
+
const next = new Set(advertised);
|
|
39
|
+
if (checked) {
|
|
40
|
+
next.add(code);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
next.delete(code);
|
|
44
|
+
}
|
|
45
|
+
setSystemAvailableLocales([...next]);
|
|
46
|
+
}, [mutationsBlocked, advertised, setSystemAvailableLocales]);
|
|
47
|
+
if (contentLocales.length === 0) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return (<div className={cx('byline-form-available-locales', styles.container)}>
|
|
51
|
+
<Label id="available-locales" htmlFor="available-locales-group" label={t('availableLocalesWidget.label')}/>
|
|
52
|
+
<div id="available-locales-group" className={cx('byline-form-available-locales-list', styles.list)} role="group" aria-labelledby="label-for-available-locales" aria-describedby="available-locales-description">
|
|
53
|
+
{contentLocales.map(({ code, label }) => {
|
|
54
|
+
const checked = advertisedSet.has(code);
|
|
55
|
+
const { intent, disabled } = reconcileLocaleState(checked, ledgerSet.has(code));
|
|
56
|
+
return (<Checkbox key={code} id={`available-locale-${code}`} name={`__availableLocale_${code}__`} label={label} intent={intent} checked={checked} disabled={mutationsBlocked || disabled} onCheckedChange={(value) => toggle(code, value === true)}/>);
|
|
57
|
+
})}
|
|
58
|
+
</div>
|
|
59
|
+
<span id="available-locales-description" className={cx('byline-form-available-locales-sr-only', styles['sr-only'])}>
|
|
60
|
+
{t('availableLocalesWidget.srDescription')}
|
|
61
|
+
</span>
|
|
62
|
+
</div>);
|
|
63
|
+
};
|