@byline/admin 6.6.0 → 6.7.1
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.d.ts +7 -1
- package/dist/forms/available-locales-widget.js +8 -3
- package/dist/forms/available-locales-widget.jsx +63 -0
- package/dist/forms/document-actions.jsx +486 -0
- package/dist/forms/form-context.d.ts +3 -2
- package/dist/forms/form-context.jsx +598 -0
- package/dist/forms/form-modals.d.ts +5 -1
- package/dist/forms/form-modals.js +4 -0
- package/dist/forms/form-modals.jsx +128 -0
- package/dist/forms/form-page-chrome.js +2 -1
- 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
- package/src/forms/available-locales-widget.tsx +15 -1
- package/src/forms/form-context.tsx +3 -2
- package/src/forms/form-modals.tsx +12 -1
- package/src/forms/form-page-chrome.tsx +1 -0
- package/src/forms/locale-visibility-copy.test.tsx +180 -0
|
@@ -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.
|
|
5
|
+
"version": "6.7.1",
|
|
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.5",
|
|
186
|
-
"@byline/analytics": "6.
|
|
187
|
-
"@byline/
|
|
188
|
-
"@byline/core": "6.
|
|
189
|
-
"@byline/
|
|
190
|
-
"@byline/
|
|
191
|
-
"@byline/ui": "6.
|
|
186
|
+
"@byline/analytics": "6.7.1",
|
|
187
|
+
"@byline/auth": "6.7.1",
|
|
188
|
+
"@byline/core": "6.7.1",
|
|
189
|
+
"@byline/analytics-agent": "6.7.1",
|
|
190
|
+
"@byline/i18n": "6.7.1",
|
|
191
|
+
"@byline/ui": "6.7.1"
|
|
192
192
|
},
|
|
193
193
|
"peerDependencies": {
|
|
194
194
|
"react": "^19.0.0",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { useCallback, useMemo } from 'react'
|
|
12
12
|
|
|
13
13
|
import { useTranslation } from '@byline/i18n/react'
|
|
14
|
-
import { Checkbox, Label } from '@byline/ui/react'
|
|
14
|
+
import { Checkbox, HelpText, Label } from '@byline/ui/react'
|
|
15
15
|
import cx from 'clsx'
|
|
16
16
|
|
|
17
17
|
import { reconcileLocaleState } from './available-locales-reconcile'
|
|
@@ -36,6 +36,12 @@ export interface AvailableLocalesWidgetProps {
|
|
|
36
36
|
* surface supplies it (Slice 6) — in which case every row renders neutral.
|
|
37
37
|
*/
|
|
38
38
|
availableVersionLocales: readonly string[]
|
|
39
|
+
/**
|
|
40
|
+
* The document's source language. Its row explains that the source stays
|
|
41
|
+
* available while published whether or not it is checked; the checkbox only
|
|
42
|
+
* controls whether it is advertised.
|
|
43
|
+
*/
|
|
44
|
+
sourceLocale?: string
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/**
|
|
@@ -54,6 +60,7 @@ export const AvailableLocalesWidget = ({
|
|
|
54
60
|
disabled: mutationsBlocked = false,
|
|
55
61
|
contentLocales,
|
|
56
62
|
availableVersionLocales,
|
|
63
|
+
sourceLocale,
|
|
57
64
|
}: AvailableLocalesWidgetProps) => {
|
|
58
65
|
const { t } = useTranslation('byline-admin')
|
|
59
66
|
const { setSystemAvailableLocales } = useFormContext()
|
|
@@ -107,10 +114,17 @@ export const AvailableLocalesWidget = ({
|
|
|
107
114
|
checked={checked}
|
|
108
115
|
disabled={mutationsBlocked || disabled}
|
|
109
116
|
onCheckedChange={(value) => toggle(code, value === true)}
|
|
117
|
+
helpText={
|
|
118
|
+
code === sourceLocale ? t('availableLocalesWidget.sourceRowHint') : undefined
|
|
119
|
+
}
|
|
110
120
|
/>
|
|
111
121
|
)
|
|
112
122
|
})}
|
|
113
123
|
</div>
|
|
124
|
+
<HelpText
|
|
125
|
+
className="byline-form-available-locales-hint"
|
|
126
|
+
text={t('availableLocalesWidget.hint')}
|
|
127
|
+
/>
|
|
114
128
|
<span
|
|
115
129
|
id="available-locales-description"
|
|
116
130
|
className={cx('byline-form-available-locales-sr-only', styles['sr-only'])}
|
|
@@ -73,8 +73,9 @@ const sameLocaleSet = (a: string[], b: string[]): boolean => {
|
|
|
73
73
|
* Why the form is dirty, partitioned by write semantics — drives the single
|
|
74
74
|
* Save button. `content` mints a new version (normal workflow). `direct-write`
|
|
75
75
|
* is an immediate, non-versioned write of the document-grain system fields
|
|
76
|
-
* (path / advertised locales) that does NOT reset workflow status. `both`
|
|
77
|
-
*
|
|
76
|
+
* (path / advertised locales) that does NOT reset workflow status. `both`
|
|
77
|
+
* saves the two together: one request, one guarded transaction, so both commit
|
|
78
|
+
* or neither does. See docs/08-internationalization/03-content-locales.md.
|
|
78
79
|
*/
|
|
79
80
|
export type DirtyReason = 'none' | 'content' | 'direct-write' | 'both'
|
|
80
81
|
|
|
@@ -54,7 +54,11 @@ export const UnsavedChangesModal = ({ onClose }: { onClose: () => void }) => {
|
|
|
54
54
|
* Confirms an immediate, non-versioned write of the document-grain system
|
|
55
55
|
* fields (path / advertised locales) — which does NOT reset workflow status.
|
|
56
56
|
* When content is also dirty, the copy reassures that content edits still
|
|
57
|
-
* follow the normal revision + publish workflow
|
|
57
|
+
* follow the normal revision + publish workflow, and, for a locale change,
|
|
58
|
+
* explains that locale and content changes commit together (one guarded
|
|
59
|
+
* transaction, so a failed save applies neither), that an already-published
|
|
60
|
+
* checked translation becomes public once saved, and that a draft-only
|
|
61
|
+
* translation stays withheld until it is published. See docs/08-internationalization/index.md.
|
|
58
62
|
*/
|
|
59
63
|
export const SystemFieldsConfirmModal = ({
|
|
60
64
|
contentDirty,
|
|
@@ -92,6 +96,13 @@ export const SystemFieldsConfirmModal = ({
|
|
|
92
96
|
{t('forms.systemFieldsConfirm.contentNote')}
|
|
93
97
|
</p>
|
|
94
98
|
)}
|
|
99
|
+
{/* Locale and content changes commit together. Say what the locale
|
|
100
|
+
change does to public delivery once the save succeeds. */}
|
|
101
|
+
{contentDirty && availableLocalesDirty && (
|
|
102
|
+
<p className={cx('byline-form-system-fields-locales-note', 'm-0 mt-2')}>
|
|
103
|
+
{t('forms.systemFieldsConfirm.localesContentNote')}
|
|
104
|
+
</p>
|
|
105
|
+
)}
|
|
95
106
|
<p
|
|
96
107
|
className="m-0 mt-2"
|
|
97
108
|
style={
|
|
@@ -437,6 +437,7 @@ export const FormSidebarWidgets = ({
|
|
|
437
437
|
availableVersionLocales={
|
|
438
438
|
(initialData?._availableVersionLocales as string[] | undefined) ?? []
|
|
439
439
|
}
|
|
440
|
+
sourceLocale={(initialData?.sourceLocale as string | undefined) ?? defaultLocale}
|
|
440
441
|
/>
|
|
441
442
|
)}
|
|
442
443
|
</>
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The advertised-locales widget and the immediate-save confirmation explain
|
|
11
|
+
* what a checkbox does to public delivery. Rendered with the real English
|
|
12
|
+
* admin bundle, so a renamed or deleted key fails here rather than showing a
|
|
13
|
+
* raw key to an editor.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { act, type ReactNode } from 'react'
|
|
17
|
+
|
|
18
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
19
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
20
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
22
|
+
|
|
23
|
+
vi.mock('@byline/ui/react', () => {
|
|
24
|
+
const Modal = ({ children }: { children?: ReactNode }) => <div>{children}</div>
|
|
25
|
+
const Part = ({ children }: { children?: ReactNode }) => <div>{children}</div>
|
|
26
|
+
Modal.Container = Part
|
|
27
|
+
Modal.Header = Part
|
|
28
|
+
Modal.Content = Part
|
|
29
|
+
Modal.Actions = Part
|
|
30
|
+
return {
|
|
31
|
+
Label: ({ label }: { label?: string }) => <span>{label}</span>,
|
|
32
|
+
HelpText: ({ text }: { text: string }) => <p data-testid="group-hint">{text}</p>,
|
|
33
|
+
Checkbox: ({ id, label, helpText }: { id: string; label: string; helpText?: string }) => (
|
|
34
|
+
<div data-testid={id}>
|
|
35
|
+
<span>{label}</span>
|
|
36
|
+
{helpText ? <span data-testid={`${id}-hint`}>{helpText}</span> : null}
|
|
37
|
+
</div>
|
|
38
|
+
),
|
|
39
|
+
Modal,
|
|
40
|
+
Button: ({ children }: { children?: ReactNode }) => <button type="button">{children}</button>,
|
|
41
|
+
IconButton: ({ children }: { children?: ReactNode }) => (
|
|
42
|
+
<button type="button">{children}</button>
|
|
43
|
+
),
|
|
44
|
+
CloseIcon: () => null,
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
vi.mock('./form-context', () => ({
|
|
49
|
+
useFormContext: () => ({ setSystemAvailableLocales: vi.fn() }),
|
|
50
|
+
useSystemAvailableLocales: () => ['en'],
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
import { AvailableLocalesWidget } from './available-locales-widget'
|
|
54
|
+
import { SystemFieldsConfirmModal } from './form-modals'
|
|
55
|
+
|
|
56
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
57
|
+
|
|
58
|
+
const en: Record<string, string> = adminTranslations({ locales: ['en'] }).en?.['byline-admin'] ?? {}
|
|
59
|
+
|
|
60
|
+
describe('locale visibility copy', () => {
|
|
61
|
+
let container: HTMLDivElement
|
|
62
|
+
let root: Root
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
container = document.createElement('div')
|
|
66
|
+
document.body.appendChild(container)
|
|
67
|
+
root = createRoot(container)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
act(() => {
|
|
72
|
+
root.unmount()
|
|
73
|
+
})
|
|
74
|
+
container.remove()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const render = (node: ReactNode) => {
|
|
78
|
+
act(() => {
|
|
79
|
+
root.render(
|
|
80
|
+
<I18nProvider
|
|
81
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
82
|
+
activeLocale="en"
|
|
83
|
+
defaultLocale="en"
|
|
84
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
85
|
+
>
|
|
86
|
+
{node}
|
|
87
|
+
</I18nProvider>
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const text = (testId: string) =>
|
|
93
|
+
container.querySelector(`[data-testid="${testId}"]`)?.textContent ?? null
|
|
94
|
+
|
|
95
|
+
it('explains source availability on the source row only, and delivery for the group', () => {
|
|
96
|
+
render(
|
|
97
|
+
<AvailableLocalesWidget
|
|
98
|
+
contentLocales={[
|
|
99
|
+
{ code: 'en', label: 'English' },
|
|
100
|
+
{ code: 'es', label: 'Español' },
|
|
101
|
+
]}
|
|
102
|
+
availableVersionLocales={['en', 'es']}
|
|
103
|
+
sourceLocale="en"
|
|
104
|
+
/>
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
expect(text('available-locale-en-hint')).toBe(en['availableLocalesWidget.sourceRowHint'])
|
|
108
|
+
expect(text('available-locale-en-hint')).toContain('available while published')
|
|
109
|
+
expect(text('available-locale-es-hint')).toBeNull()
|
|
110
|
+
expect(text('group-hint')).toBe(en['availableLocalesWidget.hint'])
|
|
111
|
+
expect(text('group-hint')).toContain('unpublished draft')
|
|
112
|
+
expect(container.textContent).toContain('whether or not it is checked')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('moves the source note with the document source language', () => {
|
|
116
|
+
render(
|
|
117
|
+
<AvailableLocalesWidget
|
|
118
|
+
contentLocales={[
|
|
119
|
+
{ code: 'en', label: 'English' },
|
|
120
|
+
{ code: 'es', label: 'Español' },
|
|
121
|
+
]}
|
|
122
|
+
availableVersionLocales={['en', 'es']}
|
|
123
|
+
sourceLocale="es"
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(text('available-locale-es-hint')).toBe(en['availableLocalesWidget.sourceRowHint'])
|
|
128
|
+
expect(text('available-locale-en-hint')).toBeNull()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('explains immediate public delivery for a locale-only save', () => {
|
|
132
|
+
render(
|
|
133
|
+
<SystemFieldsConfirmModal
|
|
134
|
+
contentDirty={false}
|
|
135
|
+
pathDirty={false}
|
|
136
|
+
availableLocalesDirty={true}
|
|
137
|
+
onCancel={() => {}}
|
|
138
|
+
onConfirm={() => {}}
|
|
139
|
+
/>
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
expect(container.textContent).toContain(en['forms.systemFieldsConfirm.bulletLocales'])
|
|
143
|
+
expect(container.textContent).toContain('appears on the public site straight away')
|
|
144
|
+
expect(container.textContent).not.toContain(en['forms.systemFieldsConfirm.localesContentNote'])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('explains the save order when locale and content changes are saved together', () => {
|
|
148
|
+
render(
|
|
149
|
+
<SystemFieldsConfirmModal
|
|
150
|
+
contentDirty={true}
|
|
151
|
+
pathDirty={false}
|
|
152
|
+
availableLocalesDirty={true}
|
|
153
|
+
onCancel={() => {}}
|
|
154
|
+
onConfirm={() => {}}
|
|
155
|
+
/>
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const note = en['forms.systemFieldsConfirm.localesContentNote']
|
|
159
|
+
expect(container.textContent).toContain(note)
|
|
160
|
+
// The combined save is one guarded commit: a failed save applies neither.
|
|
161
|
+
expect(note).toContain('saved together')
|
|
162
|
+
expect(note).toContain('neither is applied')
|
|
163
|
+
expect(note).toContain('stays hidden until it is published')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('adds no locale note to a combined path and content save', () => {
|
|
167
|
+
render(
|
|
168
|
+
<SystemFieldsConfirmModal
|
|
169
|
+
contentDirty={true}
|
|
170
|
+
pathDirty={true}
|
|
171
|
+
availableLocalesDirty={false}
|
|
172
|
+
onCancel={() => {}}
|
|
173
|
+
onConfirm={() => {}}
|
|
174
|
+
/>
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
expect(container.textContent).not.toContain(en['forms.systemFieldsConfirm.localesContentNote'])
|
|
178
|
+
expect(container.textContent).not.toContain(en['forms.systemFieldsConfirm.bulletLocales'])
|
|
179
|
+
})
|
|
180
|
+
})
|