@byline/admin 6.0.2 → 6.1.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/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.d.ts +8 -2
- package/dist/forms/form-renderer.js +3 -2
- 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.d.ts +9 -1
- package/dist/forms/path-widget.js +8 -5
- 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
- package/src/forms/form-renderer-capabilities.test.tsx +72 -2
- package/src/forms/form-renderer.tsx +14 -2
- package/src/forms/path-widget.test.tsx +96 -2
- package/src/forms/path-widget.tsx +31 -11
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { normalizeRootRelativeRedirect, resolveSignInFormRedirect } from './safe-redirect.js';
|
|
3
|
+
describe('normalizeRootRelativeRedirect', () => {
|
|
4
|
+
it.each([
|
|
5
|
+
['/cms', '/cms'],
|
|
6
|
+
['/cms/account?tab=profile#name', '/cms/account?tab=profile#name'],
|
|
7
|
+
])('accepts %j', (value, expected) => {
|
|
8
|
+
expect(normalizeRootRelativeRedirect(value)).toBe(expected);
|
|
9
|
+
});
|
|
10
|
+
it.each([
|
|
11
|
+
'',
|
|
12
|
+
' /cms',
|
|
13
|
+
'https://evil.test',
|
|
14
|
+
'//evil.test',
|
|
15
|
+
'/\\evil.test',
|
|
16
|
+
'/cms\\account',
|
|
17
|
+
'/cms\naccount',
|
|
18
|
+
'%2Fcms',
|
|
19
|
+
'/%2F%2Fevil.test',
|
|
20
|
+
'/cms/%5cevil.test',
|
|
21
|
+
'/cms/%2e/account',
|
|
22
|
+
'/cms/../account',
|
|
23
|
+
'/cms/./account',
|
|
24
|
+
'/cms\u0085account',
|
|
25
|
+
])('rejects %j', (value) => {
|
|
26
|
+
expect(normalizeRootRelativeRedirect(value)).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
describe('resolveSignInFormRedirect', () => {
|
|
30
|
+
it('uses a safe redirectTo without evaluating the fallback', () => {
|
|
31
|
+
expect(resolveSignInFormRedirect('/cms/account', '/cms')).toBe('/cms/account');
|
|
32
|
+
expect(resolveSignInFormRedirect('/cms/account', () => {
|
|
33
|
+
throw new Error('fallback must be lazy');
|
|
34
|
+
})).toBe('/cms/account');
|
|
35
|
+
});
|
|
36
|
+
it('uses the safe configured fallback without permitting an open redirect', () => {
|
|
37
|
+
expect(resolveSignInFormRedirect('//evil.test', '/cms')).toBe('/cms');
|
|
38
|
+
expect(resolveSignInFormRedirect(undefined, 'https://evil.test')).toBe('/');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
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 './group.module.css';
|
|
10
|
+
/**
|
|
11
|
+
* Labelled fieldset clustering related fields together.
|
|
12
|
+
*
|
|
13
|
+
* Used by `FormRenderer` when a `CollectionAdminConfig` declares a `groups`
|
|
14
|
+
* primitive. Renders a bordered, padded `<fieldset>` with an optional
|
|
15
|
+
* `<legend>` for the label.
|
|
16
|
+
*
|
|
17
|
+
* Stable override handles: `.byline-admin-group` on the fieldset and
|
|
18
|
+
* `.byline-admin-group-legend` on the legend (alongside the hashed
|
|
19
|
+
* CSS-modules locals).
|
|
20
|
+
*/
|
|
21
|
+
export const AdminGroup = ({ label, children, className }) => {
|
|
22
|
+
return (<fieldset className={cx('byline-admin-group', styles.group, className)}>
|
|
23
|
+
{label && <legend className={cx('byline-admin-group-legend', styles.legend)}>{label}</legend>}
|
|
24
|
+
{children}
|
|
25
|
+
</fieldset>);
|
|
26
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
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 './row.module.css';
|
|
10
|
+
/**
|
|
11
|
+
* Horizontal flex-row layout for admin form fields.
|
|
12
|
+
*
|
|
13
|
+
* Used by `FormRenderer` when a `CollectionAdminConfig` declares a `rows`
|
|
14
|
+
* primitive. Members are rendered side-by-side above the `sm` breakpoint
|
|
15
|
+
* and stack vertically below it. `flex-1` + `min-width: 0` lets two text
|
|
16
|
+
* inputs share the row evenly without overflowing.
|
|
17
|
+
*
|
|
18
|
+
* The element carries `.byline-admin-row` as a stable global class for
|
|
19
|
+
* host overrides (alongside the hashed CSS-modules local).
|
|
20
|
+
*/
|
|
21
|
+
export const AdminRow = ({ children, className }) => {
|
|
22
|
+
return <div className={cx('byline-admin-row', styles.row, className)}>{children}</div>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { useTranslation } from '@byline/i18n/react';
|
|
10
|
+
import { Badge } from '@byline/ui/react';
|
|
11
|
+
import cx from 'clsx';
|
|
12
|
+
import styles from './tabs.module.css';
|
|
13
|
+
/**
|
|
14
|
+
* Tabs navigation bar for admin form layouts.
|
|
15
|
+
*
|
|
16
|
+
* Used by FormRenderer when a CollectionAdminConfig declares a `tabs` array.
|
|
17
|
+
* Each tab is a simple button with a bottom-border active indicator.
|
|
18
|
+
* Inactive tabs show a subtle hover state. Fully dark-mode aware.
|
|
19
|
+
*
|
|
20
|
+
* Stable override handles: `.byline-admin-tabs`, `.byline-admin-tab`,
|
|
21
|
+
* `.byline-admin-tab-active`, `.byline-admin-tab-label`,
|
|
22
|
+
* `.byline-admin-tab-badge`.
|
|
23
|
+
*/
|
|
24
|
+
export const AdminTabs = ({ tabs, activeTab, onChange, errorCounts, className, }) => {
|
|
25
|
+
const { t } = useTranslation('byline-admin');
|
|
26
|
+
return (<div role="tablist" aria-label={t('presentation.formTabsAriaLabel')} className={cx('byline-admin-tabs', styles.tabs, className)}>
|
|
27
|
+
{tabs.map((tab) => {
|
|
28
|
+
const isActive = tab.name === activeTab;
|
|
29
|
+
return (<button key={tab.name} type="button" role="tab" aria-selected={isActive} onClick={() => onChange(tab.name)} className={cx('byline-admin-tab', styles.tab, isActive && ['byline-admin-tab-active', styles['tab-active']])}>
|
|
30
|
+
<span className={cx('byline-admin-tab-label', styles.label)}>
|
|
31
|
+
{tab.label}
|
|
32
|
+
{(errorCounts?.[tab.name] ?? 0) > 0 && (<Badge intent="danger" className={cx('byline-admin-tab-badge', styles.badge)}>
|
|
33
|
+
{errorCounts?.[tab.name]}
|
|
34
|
+
</Badge>)}
|
|
35
|
+
</span>
|
|
36
|
+
</button>);
|
|
37
|
+
})}
|
|
38
|
+
</div>);
|
|
39
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
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 { createContext, useContext } from 'react';
|
|
9
|
+
const AdminServicesContext = createContext(null);
|
|
10
|
+
export const BylineAdminServicesProvider = ({ services, children, }) => (<AdminServicesContext.Provider value={services}>{children}</AdminServicesContext.Provider>);
|
|
11
|
+
export const useBylineAdminServices = () => {
|
|
12
|
+
const ctx = useContext(AdminServicesContext);
|
|
13
|
+
if (!ctx) {
|
|
14
|
+
throw new Error('@byline/admin: BylineAdminServicesProvider missing. Wrap your admin tree with <BylineAdminServicesProvider services={…} />.');
|
|
15
|
+
}
|
|
16
|
+
return ctx;
|
|
17
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
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 { useEffect, useState } from 'react';
|
|
10
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
11
|
+
import { CloseIcon, IconButton, LoaderRing, Modal } from '@byline/ui/react';
|
|
12
|
+
import cx from 'clsx';
|
|
13
|
+
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued';
|
|
14
|
+
import styles from './diff-modal.module.css';
|
|
15
|
+
// Keys that are per-version metadata rather than content — strip before diffing
|
|
16
|
+
// so the diff focuses on meaningful content changes. ClientDocument-shape
|
|
17
|
+
// metadata keys after the Phase 7 admin migration.
|
|
18
|
+
const STRIP_KEYS = new Set([
|
|
19
|
+
'id',
|
|
20
|
+
'versionId',
|
|
21
|
+
'path',
|
|
22
|
+
'status',
|
|
23
|
+
'createdAt',
|
|
24
|
+
'updatedAt',
|
|
25
|
+
'hasPublishedVersion',
|
|
26
|
+
'_publishedVersion',
|
|
27
|
+
]);
|
|
28
|
+
function stripMeta(doc) {
|
|
29
|
+
// With the nested document shape, extract just the fields for diffing.
|
|
30
|
+
if (doc.fields && typeof doc.fields === 'object') {
|
|
31
|
+
return doc.fields;
|
|
32
|
+
}
|
|
33
|
+
return Object.fromEntries(Object.entries(doc).filter(([k]) => !STRIP_KEYS.has(k)));
|
|
34
|
+
}
|
|
35
|
+
export function DiffModal({ isOpen, onDismiss, collection, documentId, versionId, versionLabel, currentDocument, locale, loadHistoricalVersion, }) {
|
|
36
|
+
const { t } = useTranslation('byline-admin');
|
|
37
|
+
const [historicalDoc, setHistoricalDoc] = useState(null);
|
|
38
|
+
const [loading, setLoading] = useState(false);
|
|
39
|
+
const [error, setError] = useState(null);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!isOpen || !versionId)
|
|
42
|
+
return;
|
|
43
|
+
let cancelled = false;
|
|
44
|
+
setLoading(true);
|
|
45
|
+
setError(null);
|
|
46
|
+
setHistoricalDoc(null);
|
|
47
|
+
loadHistoricalVersion(collection, documentId, versionId, locale)
|
|
48
|
+
.then((doc) => {
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return;
|
|
51
|
+
setHistoricalDoc(doc);
|
|
52
|
+
})
|
|
53
|
+
.catch((err) => {
|
|
54
|
+
if (cancelled)
|
|
55
|
+
return;
|
|
56
|
+
setError(err instanceof Error ? err.message : t('diffModal.loadFailed'));
|
|
57
|
+
})
|
|
58
|
+
.finally(() => {
|
|
59
|
+
if (!cancelled)
|
|
60
|
+
setLoading(false);
|
|
61
|
+
});
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
};
|
|
65
|
+
}, [isOpen, collection, documentId, versionId, locale, loadHistoricalVersion, t]);
|
|
66
|
+
const currentStr = currentDocument ? JSON.stringify(stripMeta(currentDocument), null, 2) : '';
|
|
67
|
+
const historicalStr = historicalDoc ? JSON.stringify(stripMeta(historicalDoc), null, 2) : '';
|
|
68
|
+
return (<Modal isOpen={isOpen} closeOnOverlayClick={true} onDismiss={onDismiss}>
|
|
69
|
+
<Modal.Container style={{
|
|
70
|
+
width: '96vw',
|
|
71
|
+
maxWidth: '96vw',
|
|
72
|
+
height: '90vh',
|
|
73
|
+
maxHeight: '90vh',
|
|
74
|
+
display: 'flex',
|
|
75
|
+
flexDirection: 'column',
|
|
76
|
+
overflow: 'hidden',
|
|
77
|
+
}}>
|
|
78
|
+
<Modal.Header className={cx('byline-diff-modal-header', styles.header)}>
|
|
79
|
+
<div className={cx('byline-diff-modal-title-stack', styles['title-stack'])}>
|
|
80
|
+
<h3 className={cx('byline-diff-modal-title', styles.title)}>{t('diffModal.title')}</h3>
|
|
81
|
+
<p className={cx('byline-diff-modal-subtitle', styles.subtitle)}>
|
|
82
|
+
{t('diffModal.subtitleBefore')}{' '}
|
|
83
|
+
<span className={cx('byline-diff-modal-version', styles.version)}>
|
|
84
|
+
{versionLabel}
|
|
85
|
+
</span>{' '}
|
|
86
|
+
{t('diffModal.subtitleAfter')}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
<IconButton onClick={onDismiss} size="xs" aria-label={t('diffModal.closeAriaLabel')}>
|
|
90
|
+
<CloseIcon width="15px" height="15px"/>
|
|
91
|
+
</IconButton>
|
|
92
|
+
</Modal.Header>
|
|
93
|
+
|
|
94
|
+
<Modal.Content className={cx('byline-diff-modal-content', styles.content)} style={{ minHeight: 0 }}>
|
|
95
|
+
{loading && (<div className={cx('byline-diff-modal-state', styles.state)}>
|
|
96
|
+
<LoaderRing size={28} color="#666666"/>
|
|
97
|
+
<span>{t('diffModal.loading')}</span>
|
|
98
|
+
</div>)}
|
|
99
|
+
|
|
100
|
+
{error && (<div className={cx('byline-diff-modal-state', 'byline-diff-modal-error', styles.state, styles.error)}>
|
|
101
|
+
{error}
|
|
102
|
+
</div>)}
|
|
103
|
+
|
|
104
|
+
{!loading && !error && historicalDoc && (<div className={cx('byline-diff-modal-viewer', styles.viewer)}>
|
|
105
|
+
<ReactDiffViewer oldValue={historicalStr} newValue={currentStr} splitView={true} compareMethod={DiffMethod.LINES} useDarkTheme={true} leftTitle={versionLabel} rightTitle={t('diffModal.currentVersion')} hideLineNumbers={false}/>
|
|
106
|
+
</div>)}
|
|
107
|
+
</Modal.Content>
|
|
108
|
+
</Modal.Container>
|
|
109
|
+
</Modal>);
|
|
110
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
import { Badge } from '@byline/ui/react';
|
|
9
|
+
import cx from 'clsx';
|
|
10
|
+
import styles from './source-locale-badge.module.css';
|
|
11
|
+
/**
|
|
12
|
+
* Small neutral badge indicating a document's content **source locale** — the
|
|
13
|
+
* locale it was authored in (its anchor: fallback floor, path locale,
|
|
14
|
+
* completeness yardstick). Rendered next to the document title in the edit and
|
|
15
|
+
* list views. Mirrors the localized-field {@link LocaleBadge} in spirit but uses
|
|
16
|
+
* the shared {@link Badge} with the `noeffect` (neutral) intent.
|
|
17
|
+
*
|
|
18
|
+
* NOTE: currently rendered for *every* document so the anchor is visible during
|
|
19
|
+
* development. The intended end state is to show it only when `locale` differs
|
|
20
|
+
* from the system's current default content locale (a normal single-default
|
|
21
|
+
* install then shows nothing). See docs/08-internationalization/index.md.
|
|
22
|
+
*
|
|
23
|
+
* Stable override handle: `.byline-source-locale-badge`.
|
|
24
|
+
*/
|
|
25
|
+
export const SourceLocaleBadge = ({ locale, className }) => (<Badge intent="noeffect" render={<span />} title={`Primary content language: ${locale.toUpperCase()}`} className={cx('byline-source-locale-badge', styles.badge, className)}>
|
|
26
|
+
{locale.toUpperCase()}
|
|
27
|
+
</Badge>);
|
|
28
|
+
SourceLocaleBadge.displayName = 'SourceLocaleBadge';
|
|
@@ -0,0 +1,46 @@
|
|
|
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 { WORKFLOW_STATUS_ARCHIVED, WORKFLOW_STATUS_DRAFT, WORKFLOW_STATUS_PUBLISHED, } from '@byline/core';
|
|
9
|
+
import { useTranslation } from '@byline/i18n/react';
|
|
10
|
+
import { Badge } from '@byline/ui/react';
|
|
11
|
+
import cx from 'clsx';
|
|
12
|
+
import styles from './status-badge.module.css';
|
|
13
|
+
function statusIntent(status) {
|
|
14
|
+
switch (status) {
|
|
15
|
+
case WORKFLOW_STATUS_PUBLISHED:
|
|
16
|
+
return 'success';
|
|
17
|
+
case WORKFLOW_STATUS_DRAFT:
|
|
18
|
+
return 'warning';
|
|
19
|
+
case WORKFLOW_STATUS_ARCHIVED:
|
|
20
|
+
return 'info';
|
|
21
|
+
default:
|
|
22
|
+
return 'noeffect';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Compact badge for workflow status values. Maps the three built-in
|
|
27
|
+
* statuses (draft, published, archived) to semantic intents and falls
|
|
28
|
+
* back to `noeffect` for any custom workflow statuses.
|
|
29
|
+
*
|
|
30
|
+
* When `hasPublishedVersion` is true and the current status is not
|
|
31
|
+
* `published`, a small green dot is rendered before the badge to
|
|
32
|
+
* indicate that a published version is live.
|
|
33
|
+
*
|
|
34
|
+
* Stable override handles: `.byline-status-badge-wrap`,
|
|
35
|
+
* `.byline-status-badge-dot`, `.byline-status-badge`.
|
|
36
|
+
*/
|
|
37
|
+
export const StatusBadge = ({ status, workflowStatuses, hasPublishedVersion, }) => {
|
|
38
|
+
const { t } = useTranslation('byline-admin');
|
|
39
|
+
const label = workflowStatuses.find((s) => s.name === status)?.label ?? String(status ?? '');
|
|
40
|
+
return (<span className={cx('byline-status-badge-wrap', styles.wrap)}>
|
|
41
|
+
{hasPublishedVersion === true && status !== 'published' && (<span title={t('statusBadge.publishedVersionLive')} className={cx('byline-status-badge-dot', styles.dot)}/>)}
|
|
42
|
+
<Badge intent={statusIntent(status)} className={cx('byline-status-badge', styles.badge)}>
|
|
43
|
+
{label}
|
|
44
|
+
</Badge>
|
|
45
|
+
</span>);
|
|
46
|
+
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "6.0
|
|
5
|
+
"version": "6.1.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -183,12 +183,12 @@
|
|
|
183
183
|
"react-diff-viewer-continued": "^4.4.0",
|
|
184
184
|
"uuid": "^14.0.2",
|
|
185
185
|
"zod": "^4.6.2",
|
|
186
|
-
"@byline/analytics": "6.0
|
|
187
|
-
"@byline/
|
|
188
|
-
"@byline/
|
|
189
|
-
"@byline/
|
|
190
|
-
"@byline/
|
|
191
|
-
"@byline/
|
|
186
|
+
"@byline/analytics": "6.1.0",
|
|
187
|
+
"@byline/analytics-agent": "6.1.0",
|
|
188
|
+
"@byline/core": "6.1.0",
|
|
189
|
+
"@byline/auth": "6.1.0",
|
|
190
|
+
"@byline/i18n": "6.1.0",
|
|
191
|
+
"@byline/ui": "6.1.0"
|
|
192
192
|
},
|
|
193
193
|
"peerDependencies": {
|
|
194
194
|
"react": "^19.0.0",
|
|
@@ -46,8 +46,8 @@ vi.mock('@byline/ui/react', async (importOriginal) => {
|
|
|
46
46
|
Separator: () => <hr />,
|
|
47
47
|
},
|
|
48
48
|
Modal,
|
|
49
|
-
Input: ({ name, value, onChange }: any) => (
|
|
50
|
-
<input name={name} value={value ?? ''} onChange={onChange} />
|
|
49
|
+
Input: ({ name, value, onChange, readOnly }: any) => (
|
|
50
|
+
<input name={name} value={value ?? ''} onChange={onChange} readOnly={readOnly ?? false} />
|
|
51
51
|
),
|
|
52
52
|
}
|
|
53
53
|
})
|
|
@@ -130,6 +130,76 @@ describe('FormRenderer capabilities', () => {
|
|
|
130
130
|
expect(input?.value).toBe('about-us')
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
it('forwards adminConfig.lockPath to the path widget', () => {
|
|
134
|
+
render({ ...baseProps, adminConfig: { slug: 'pages', lockPath: true } })
|
|
135
|
+
|
|
136
|
+
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
137
|
+
expect(input?.readOnly).toBe(true)
|
|
138
|
+
expect(input?.value).toBe('about-us')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('leaves the path widget editable when no lockPath is declared', () => {
|
|
142
|
+
render({ ...baseProps, adminConfig: { slug: 'pages' } })
|
|
143
|
+
|
|
144
|
+
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
145
|
+
expect(input?.readOnly).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('keeps a counter source suppressing Regenerate without locking the input', () => {
|
|
149
|
+
// `sourceLocked` and `lockPath` stay independent: a counter-sourced path
|
|
150
|
+
// still has no derivable preview, but remains editable unless declared.
|
|
151
|
+
// The source needs a real value whose slug ('448') differs from the
|
|
152
|
+
// stored path ('about-us'), or Regenerate would be absent anyway and the
|
|
153
|
+
// assertion would hold even if `sourceLocked` forwarding broke.
|
|
154
|
+
render({
|
|
155
|
+
...baseProps,
|
|
156
|
+
fields: [{ name: 'ref', label: 'Ref', type: 'counter' as const }],
|
|
157
|
+
useAsPath: 'ref',
|
|
158
|
+
initialData: { id: 'doc-1', path: 'about-us', fields: { ref: 448 } },
|
|
159
|
+
adminConfig: { slug: 'pages' },
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
163
|
+
expect(input?.readOnly).toBe(false)
|
|
164
|
+
expect(container.querySelector('.byline-form-path-regenerate')).toBeNull()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('renders the locked widget on create with no useAsPath and no stored path', () => {
|
|
168
|
+
// A collection whose paths come from an external system declares no
|
|
169
|
+
// `useAsPath` and has nothing stored yet — the case the old render gate
|
|
170
|
+
// hid outright.
|
|
171
|
+
render({
|
|
172
|
+
...baseProps,
|
|
173
|
+
mode: 'create' as const,
|
|
174
|
+
useAsPath: undefined,
|
|
175
|
+
initialData: { fields: {} },
|
|
176
|
+
adminConfig: { slug: 'pages', lockPath: true },
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
expect(container.querySelector('.byline-form-path')).not.toBeNull()
|
|
180
|
+
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
181
|
+
expect(input?.readOnly).toBe(true)
|
|
182
|
+
expect(input?.value).toBe('')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('keeps the widget hidden on create without useAsPath when unlocked', () => {
|
|
186
|
+
render({
|
|
187
|
+
...baseProps,
|
|
188
|
+
mode: 'create' as const,
|
|
189
|
+
useAsPath: undefined,
|
|
190
|
+
initialData: { fields: {} },
|
|
191
|
+
adminConfig: { slug: 'pages' },
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
expect(container.querySelector('.byline-form-path')).toBeNull()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('lets showPath: false override lockPath', () => {
|
|
198
|
+
render({ ...baseProps, showPath: false, adminConfig: { slug: 'pages', lockPath: true } })
|
|
199
|
+
|
|
200
|
+
expect(container.querySelector('.byline-form-path')).toBeNull()
|
|
201
|
+
})
|
|
202
|
+
|
|
133
203
|
it('suppresses the path widget when showPath is false', () => {
|
|
134
204
|
render({ ...baseProps, showPath: false })
|
|
135
205
|
expect(container.querySelector('.byline-form-path')).toBeNull()
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
12
|
|
|
13
13
|
import type {
|
|
14
|
+
AdminResourceConfig,
|
|
14
15
|
Field,
|
|
15
|
-
FormAdminConfig,
|
|
16
16
|
GroupDefinition,
|
|
17
17
|
RowDefinition,
|
|
18
18
|
TabSetDefinition,
|
|
@@ -133,7 +133,13 @@ export interface FormRendererProps {
|
|
|
133
133
|
workflowStatuses?: WorkflowStatus[]
|
|
134
134
|
publishedVersion?: PublishedVersionInfo | null
|
|
135
135
|
initialData?: Record<string, any>
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Presentation configuration for the resource being edited — a collection
|
|
138
|
+
* or a singleton. Typed as the union rather than the shared
|
|
139
|
+
* `FormAdminConfig` base so the renderer can read collection-only members
|
|
140
|
+
* such as `lockPath`.
|
|
141
|
+
*/
|
|
142
|
+
adminConfig?: AdminResourceConfig
|
|
137
143
|
/**
|
|
138
144
|
* Name of the schema field to render as the live form heading.
|
|
139
145
|
* Sourced from `CollectionDefinition.useAsTitle` by the caller.
|
|
@@ -952,8 +958,13 @@ const FormContent = ({
|
|
|
952
958
|
{layout.main.map((name) => renderItem(name))}
|
|
953
959
|
</div>
|
|
954
960
|
<div className={cx('byline-form-sidebar', styles.sidebar)}>
|
|
961
|
+
{/* A locked collection's widget renders even with no `useAsPath`
|
|
962
|
+
and nothing stored yet: its path is managed, and the editor
|
|
963
|
+
needs to see that. `showPath: false` still wins — it marks a
|
|
964
|
+
path that must never be presented at all. */}
|
|
955
965
|
{showPath &&
|
|
956
966
|
(useAsPath ||
|
|
967
|
+
adminConfig?.lockPath === true ||
|
|
957
968
|
(typeof initialData?.path === 'string' && initialData.path.length > 0)) && (
|
|
958
969
|
<PathWidget
|
|
959
970
|
disabled={mutationsBlocked || discarding}
|
|
@@ -964,6 +975,7 @@ const FormContent = ({
|
|
|
964
975
|
mode={mode}
|
|
965
976
|
slugifier={pathSlugifier}
|
|
966
977
|
sourceLocked={pathSourceLocked}
|
|
978
|
+
lockPath={adminConfig?.lockPath}
|
|
967
979
|
/>
|
|
968
980
|
)}
|
|
969
981
|
{tree && mode === 'edit' && typeof initialData?.id === 'string' && (
|
|
@@ -110,6 +110,7 @@ describe('PathWidget', () => {
|
|
|
110
110
|
activeLocale: string
|
|
111
111
|
slugifier: (value: string, ctx: { locale: string; collectionPath: string }) => string
|
|
112
112
|
sourceLocked: boolean
|
|
113
|
+
lockPath: boolean
|
|
113
114
|
}> = {}
|
|
114
115
|
) => {
|
|
115
116
|
act(() => {
|
|
@@ -124,13 +125,14 @@ describe('PathWidget', () => {
|
|
|
124
125
|
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
125
126
|
>
|
|
126
127
|
<PathWidget
|
|
127
|
-
useAsPath={props.useAsPath
|
|
128
|
+
useAsPath={'useAsPath' in props ? props.useAsPath : 'title'}
|
|
128
129
|
collectionPath="pages"
|
|
129
130
|
defaultLocale="en"
|
|
130
131
|
activeLocale={props.activeLocale ?? 'en'}
|
|
131
132
|
mode={props.mode ?? 'create'}
|
|
132
133
|
slugifier={props.slugifier}
|
|
133
134
|
sourceLocked={props.sourceLocked}
|
|
135
|
+
lockPath={props.lockPath}
|
|
134
136
|
/>
|
|
135
137
|
</I18nProvider>
|
|
136
138
|
)
|
|
@@ -228,7 +230,7 @@ describe('PathWidget', () => {
|
|
|
228
230
|
})
|
|
229
231
|
|
|
230
232
|
it('does not render the Regenerate button when there is no useAsPath', () => {
|
|
231
|
-
setFixture({ systemPath: 'whatever', sourceValue: '' })
|
|
233
|
+
setFixture({ systemPath: 'whatever', sourceValue: 'Hello World' })
|
|
232
234
|
render({ mode: 'edit', useAsPath: undefined })
|
|
233
235
|
expect(container.querySelector('button')).toBeNull()
|
|
234
236
|
})
|
|
@@ -245,6 +247,27 @@ describe('PathWidget', () => {
|
|
|
245
247
|
expect(input.getAttribute('placeholder')).toBe('Will be saved as "0000001"')
|
|
246
248
|
})
|
|
247
249
|
|
|
250
|
+
it('shows the translation-locale hint when editing a translation unlocked', () => {
|
|
251
|
+
setFixture({ systemPath: 'my-path', sourceValue: 'My Path' })
|
|
252
|
+
render({ mode: 'edit', activeLocale: 'fr' })
|
|
253
|
+
|
|
254
|
+
const input = getInput()
|
|
255
|
+
expect(input.readOnly).toBe(true)
|
|
256
|
+
expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
|
|
257
|
+
'Path is set in the default locale ("en") and applies across translations.'
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('shows the suggested-slug hint for an un-slugified stored value', () => {
|
|
262
|
+
setFixture({ systemPath: 'Not A Slug', sourceValue: 'My Path' })
|
|
263
|
+
render({ mode: 'edit' })
|
|
264
|
+
|
|
265
|
+
expect(getInput().readOnly).toBe(false)
|
|
266
|
+
expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
|
|
267
|
+
'Suggested: "not-a-slug"'
|
|
268
|
+
)
|
|
269
|
+
})
|
|
270
|
+
|
|
248
271
|
it('suppresses the create-mode placeholder preview when the source is locked', () => {
|
|
249
272
|
setFixture({ systemPath: null, sourceValue: 1 })
|
|
250
273
|
render({ mode: 'create', sourceLocked: true })
|
|
@@ -259,4 +282,75 @@ describe('PathWidget', () => {
|
|
|
259
282
|
render({ mode: 'edit', sourceLocked: true })
|
|
260
283
|
expect(container.querySelector('button')).toBeNull()
|
|
261
284
|
})
|
|
285
|
+
|
|
286
|
+
it('renders read-only and empty when creating in a locked collection', () => {
|
|
287
|
+
setFixture({ systemPath: null, sourceValue: 'Hello World' })
|
|
288
|
+
render({ mode: 'create', lockPath: true })
|
|
289
|
+
|
|
290
|
+
const input = getInput()
|
|
291
|
+
expect(input.readOnly).toBe(true)
|
|
292
|
+
expect(input.value).toBe('')
|
|
293
|
+
expect(input.getAttribute('placeholder')).toBeNull()
|
|
294
|
+
expect(container.querySelector('button')).toBeNull()
|
|
295
|
+
expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
|
|
296
|
+
'Assigned automatically when this document is saved.'
|
|
297
|
+
)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('renders read-only and shows the stored path when editing a locked collection', () => {
|
|
301
|
+
setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' })
|
|
302
|
+
render({ mode: 'edit', lockPath: true })
|
|
303
|
+
|
|
304
|
+
const input = getInput()
|
|
305
|
+
expect(input.readOnly).toBe(true)
|
|
306
|
+
expect(input.value).toBe('catalogue-item')
|
|
307
|
+
// The stored path differs from what the source would derive, so an
|
|
308
|
+
// unlocked widget would offer Regenerate here.
|
|
309
|
+
expect(container.querySelector('button')).toBeNull()
|
|
310
|
+
expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
|
|
311
|
+
'This path is managed and cannot be edited here.'
|
|
312
|
+
)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('prefers the locked hint over the translation-locale hint', () => {
|
|
316
|
+
setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' })
|
|
317
|
+
render({ mode: 'edit', lockPath: true, activeLocale: 'fr' })
|
|
318
|
+
|
|
319
|
+
expect(getInput().readOnly).toBe(true)
|
|
320
|
+
expect(container.querySelector('[data-testid="help-text"]')?.textContent).toBe(
|
|
321
|
+
'This path is managed and cannot be edited here.'
|
|
322
|
+
)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('ignores a change event while locked — the lock is not merely visual', () => {
|
|
326
|
+
setFixture({ systemPath: 'catalogue-item', sourceValue: 'Hello World' })
|
|
327
|
+
render({ mode: 'edit', lockPath: true })
|
|
328
|
+
|
|
329
|
+
// Drive the input through the native prototype setter, exactly as the
|
|
330
|
+
// two typing tests above do. A direct `input.value = …` updates React's
|
|
331
|
+
// value tracker, which then suppresses onChange on its own — the
|
|
332
|
+
// assertion below would pass whether or not the guard exists.
|
|
333
|
+
const input = getInput()
|
|
334
|
+
act(() => {
|
|
335
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
336
|
+
window.HTMLInputElement.prototype,
|
|
337
|
+
'value'
|
|
338
|
+
)?.set
|
|
339
|
+
setter?.call(input, 'typed-over')
|
|
340
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
expect(fixture.setSystemPath).not.toHaveBeenCalled()
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('leaves a counter-sourced collection editable when lockPath is not set', () => {
|
|
347
|
+
// The explicit guard against reintroducing inference: a locked *source*
|
|
348
|
+
// suppresses the preview and Regenerate, but never the input itself.
|
|
349
|
+
setFixture({ systemPath: '0000448', sourceValue: 448 })
|
|
350
|
+
render({ mode: 'edit', sourceLocked: true })
|
|
351
|
+
|
|
352
|
+
const input = getInput()
|
|
353
|
+
expect(input.readOnly).toBe(false)
|
|
354
|
+
expect(container.querySelector('button')).toBeNull()
|
|
355
|
+
})
|
|
262
356
|
})
|