@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.
Files changed (89) hide show
  1. package/dist/exports-parity.test.node.js +37 -0
  2. package/dist/fields/array/array-field.jsx +209 -0
  3. package/dist/fields/blocks/blocks-field.jsx +248 -0
  4. package/dist/fields/checkbox/checkbox-field.jsx +24 -0
  5. package/dist/fields/code/code-editor.jsx +211 -0
  6. package/dist/fields/code/code-field.jsx +109 -0
  7. package/dist/fields/column-formatter.jsx +25 -0
  8. package/dist/fields/date-time-formatter.jsx +18 -0
  9. package/dist/fields/datetime/datetime-field.jsx +22 -0
  10. package/dist/fields/draggable-context-menu.jsx +50 -0
  11. package/dist/fields/field-admin.test.node.js +40 -0
  12. package/dist/fields/field-renderer.jsx +136 -0
  13. package/dist/fields/field-services-context.jsx +17 -0
  14. package/dist/fields/file/file-field.jsx +176 -0
  15. package/dist/fields/file/file-upload-field.jsx +89 -0
  16. package/dist/fields/group/group-field.jsx +46 -0
  17. package/dist/fields/image/image-field.jsx +183 -0
  18. package/dist/fields/image/image-upload-field.jsx +157 -0
  19. package/dist/fields/local-date-time.jsx +69 -0
  20. package/dist/fields/locale-badge.jsx +19 -0
  21. package/dist/fields/numerical/numerical-field.jsx +93 -0
  22. package/dist/fields/relation/relation-column-formatter.jsx +74 -0
  23. package/dist/fields/relation/relation-display.jsx +118 -0
  24. package/dist/fields/relation/relation-field.jsx +108 -0
  25. package/dist/fields/relation/relation-many-field.jsx +142 -0
  26. package/dist/fields/relation/relation-picker.jsx +240 -0
  27. package/dist/fields/relation/relation-summary.jsx +45 -0
  28. package/dist/fields/select/select-field.jsx +27 -0
  29. package/dist/fields/sortable-item.jsx +54 -0
  30. package/dist/fields/text/text-field.jsx +79 -0
  31. package/dist/fields/text-area/text-area-field.jsx +79 -0
  32. package/dist/forms/available-locales-reconcile.test.node.js +43 -0
  33. package/dist/forms/available-locales-widget.d.ts +7 -1
  34. package/dist/forms/available-locales-widget.js +8 -3
  35. package/dist/forms/available-locales-widget.jsx +63 -0
  36. package/dist/forms/document-actions.jsx +486 -0
  37. package/dist/forms/form-context.d.ts +3 -2
  38. package/dist/forms/form-context.jsx +598 -0
  39. package/dist/forms/form-modals.d.ts +5 -1
  40. package/dist/forms/form-modals.js +4 -0
  41. package/dist/forms/form-modals.jsx +128 -0
  42. package/dist/forms/form-page-chrome.js +2 -1
  43. package/dist/forms/form-renderer.jsx +532 -0
  44. package/dist/forms/form-status-display.jsx +69 -0
  45. package/dist/forms/navigation-guard.jsx +68 -0
  46. package/dist/forms/nested-path.test.node.js +115 -0
  47. package/dist/forms/path-widget.jsx +125 -0
  48. package/dist/forms/pending-uploads.test.node.js +19 -0
  49. package/dist/forms/repeating-items.test.node.js +29 -0
  50. package/dist/forms/scheduled-publication-control.jsx +389 -0
  51. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  52. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  53. package/dist/forms/status-transitions.test.node.js +76 -0
  54. package/dist/forms/tree-placement-widget.jsx +160 -0
  55. package/dist/forms/upload-executor.test.node.js +368 -0
  56. package/dist/forms/use-form-layout.test.node.js +69 -0
  57. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  58. package/dist/modules/admin-account/components/container.jsx +164 -0
  59. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  60. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  61. package/dist/modules/admin-account/components/update.jsx +186 -0
  62. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  63. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  64. package/dist/modules/admin-roles/components/create.jsx +155 -0
  65. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  66. package/dist/modules/admin-roles/components/update.jsx +148 -0
  67. package/dist/modules/admin-users/components/create.jsx +206 -0
  68. package/dist/modules/admin-users/components/roles.jsx +126 -0
  69. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  70. package/dist/modules/admin-users/components/update.jsx +215 -0
  71. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  72. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  73. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  74. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  75. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  76. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  77. package/dist/presentation/group.jsx +26 -0
  78. package/dist/presentation/row.jsx +23 -0
  79. package/dist/presentation/tabs.jsx +39 -0
  80. package/dist/services/admin-services-context.jsx +17 -0
  81. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  82. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  83. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  84. package/package.json +7 -7
  85. package/src/forms/available-locales-widget.tsx +15 -1
  86. package/src/forms/form-context.tsx +3 -2
  87. package/src/forms/form-modals.tsx +12 -1
  88. package/src/forms/form-page-chrome.tsx +1 -0
  89. 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,7 @@
1
+ import { describe, expectTypeOf, it } from 'vitest';
2
+ describe('SignInFormProps', () => {
3
+ it('accepts redirectTo and no destination prop', () => {
4
+ expectTypeOf().toMatchTypeOf();
5
+ expectTypeOf().toMatchTypeOf();
6
+ });
7
+ });
@@ -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.6.0",
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.6.0",
187
- "@byline/i18n": "6.6.0",
188
- "@byline/core": "6.6.0",
189
- "@byline/auth": "6.6.0",
190
- "@byline/analytics-agent": "6.6.0",
191
- "@byline/ui": "6.6.0"
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` does
77
- * each through its own write path. See docs/08-internationalization/index.md.
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. See docs/08-internationalization/index.md.
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
+ })