@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.
Files changed (86) 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.jsx +63 -0
  34. package/dist/forms/document-actions.jsx +486 -0
  35. package/dist/forms/form-context.jsx +598 -0
  36. package/dist/forms/form-modals.jsx +128 -0
  37. package/dist/forms/form-renderer.d.ts +8 -2
  38. package/dist/forms/form-renderer.js +3 -2
  39. package/dist/forms/form-renderer.jsx +532 -0
  40. package/dist/forms/form-status-display.jsx +69 -0
  41. package/dist/forms/navigation-guard.jsx +68 -0
  42. package/dist/forms/nested-path.test.node.js +115 -0
  43. package/dist/forms/path-widget.d.ts +9 -1
  44. package/dist/forms/path-widget.js +8 -5
  45. package/dist/forms/path-widget.jsx +125 -0
  46. package/dist/forms/pending-uploads.test.node.js +19 -0
  47. package/dist/forms/repeating-items.test.node.js +29 -0
  48. package/dist/forms/scheduled-publication-control.jsx +389 -0
  49. package/dist/forms/scheduled-publication-state.test.node.js +121 -0
  50. package/dist/forms/scheduled-publication-time.test.node.js +70 -0
  51. package/dist/forms/status-transitions.test.node.js +76 -0
  52. package/dist/forms/tree-placement-widget.jsx +160 -0
  53. package/dist/forms/upload-executor.test.node.js +368 -0
  54. package/dist/forms/use-form-layout.test.node.js +69 -0
  55. package/dist/modules/admin-account/components/change-password.jsx +169 -0
  56. package/dist/modules/admin-account/components/container.jsx +164 -0
  57. package/dist/modules/admin-account/components/preferences.jsx +148 -0
  58. package/dist/modules/admin-account/components/theme-switch.jsx +52 -0
  59. package/dist/modules/admin-account/components/update.jsx +186 -0
  60. package/dist/modules/admin-permissions/components/inspector.jsx +200 -0
  61. package/dist/modules/admin-preferences/schemas.test.node.js +48 -0
  62. package/dist/modules/admin-roles/components/create.jsx +155 -0
  63. package/dist/modules/admin-roles/components/permissions.jsx +239 -0
  64. package/dist/modules/admin-roles/components/update.jsx +148 -0
  65. package/dist/modules/admin-users/components/create.jsx +206 -0
  66. package/dist/modules/admin-users/components/roles.jsx +126 -0
  67. package/dist/modules/admin-users/components/set-password.jsx +133 -0
  68. package/dist/modules/admin-users/components/update.jsx +215 -0
  69. package/dist/modules/analytics/components/dashboard.jsx +208 -0
  70. package/dist/modules/analytics/components/dashboard.test.node.js +162 -0
  71. package/dist/modules/analytics/components/timeseries.jsx +193 -0
  72. package/dist/modules/auth/components/sign-in-form.jsx +89 -0
  73. package/dist/modules/auth/safe-redirect.test.node.js +40 -0
  74. package/dist/modules/auth/sign-in-form-props.test.node.js +7 -0
  75. package/dist/presentation/group.jsx +26 -0
  76. package/dist/presentation/row.jsx +23 -0
  77. package/dist/presentation/tabs.jsx +39 -0
  78. package/dist/services/admin-services-context.jsx +17 -0
  79. package/dist/widgets/diff-viewer/diff-modal.jsx +110 -0
  80. package/dist/widgets/source-locale-badge/source-locale-badge.jsx +28 -0
  81. package/dist/widgets/status-badge/status-badge.jsx +46 -0
  82. package/package.json +7 -7
  83. package/src/forms/form-renderer-capabilities.test.tsx +72 -2
  84. package/src/forms/form-renderer.tsx +14 -2
  85. package/src/forms/path-widget.test.tsx +96 -2
  86. 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,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.0.2",
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.2",
187
- "@byline/auth": "6.0.2",
188
- "@byline/analytics-agent": "6.0.2",
189
- "@byline/core": "6.0.2",
190
- "@byline/ui": "6.0.2",
191
- "@byline/i18n": "6.0.2"
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
- adminConfig?: FormAdminConfig
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 ?? 'title'}
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
  })