@byline/admin 5.1.4 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/dist/exports-parity.test.node.js +0 -37
- package/dist/fields/array/array-field.jsx +0 -209
- package/dist/fields/blocks/blocks-field.jsx +0 -248
- package/dist/fields/checkbox/checkbox-field.jsx +0 -24
- package/dist/fields/code/code-editor.jsx +0 -211
- package/dist/fields/code/code-field.jsx +0 -109
- package/dist/fields/column-formatter.jsx +0 -25
- package/dist/fields/date-time-formatter.jsx +0 -18
- package/dist/fields/datetime/datetime-field.jsx +0 -22
- package/dist/fields/draggable-context-menu.jsx +0 -50
- package/dist/fields/field-admin.test.node.js +0 -40
- package/dist/fields/field-renderer.jsx +0 -136
- package/dist/fields/field-services-context.jsx +0 -17
- package/dist/fields/file/file-field.jsx +0 -176
- package/dist/fields/file/file-upload-field.jsx +0 -89
- package/dist/fields/group/group-field.jsx +0 -46
- package/dist/fields/image/image-field.jsx +0 -183
- package/dist/fields/image/image-upload-field.jsx +0 -157
- package/dist/fields/local-date-time.jsx +0 -69
- package/dist/fields/locale-badge.jsx +0 -19
- package/dist/fields/numerical/numerical-field.jsx +0 -93
- package/dist/fields/relation/relation-column-formatter.jsx +0 -74
- package/dist/fields/relation/relation-display.jsx +0 -118
- package/dist/fields/relation/relation-field.jsx +0 -108
- package/dist/fields/relation/relation-many-field.jsx +0 -142
- package/dist/fields/relation/relation-picker.jsx +0 -240
- package/dist/fields/relation/relation-summary.jsx +0 -45
- package/dist/fields/select/select-field.jsx +0 -27
- package/dist/fields/sortable-item.jsx +0 -54
- package/dist/fields/text/text-field.jsx +0 -79
- package/dist/fields/text-area/text-area-field.jsx +0 -79
- package/dist/forms/available-locales-reconcile.test.node.js +0 -43
- package/dist/forms/available-locales-widget.jsx +0 -63
- package/dist/forms/document-actions.jsx +0 -486
- package/dist/forms/form-context.jsx +0 -598
- package/dist/forms/form-modals.jsx +0 -128
- package/dist/forms/form-renderer.jsx +0 -532
- package/dist/forms/form-status-display.jsx +0 -69
- package/dist/forms/navigation-guard.jsx +0 -68
- package/dist/forms/nested-path.test.node.js +0 -115
- package/dist/forms/path-widget.jsx +0 -125
- package/dist/forms/pending-uploads.test.node.js +0 -19
- package/dist/forms/repeating-items.test.node.js +0 -29
- package/dist/forms/scheduled-publication-control.jsx +0 -389
- package/dist/forms/scheduled-publication-state.test.node.js +0 -121
- package/dist/forms/scheduled-publication-time.test.node.js +0 -70
- package/dist/forms/status-transitions.test.node.js +0 -76
- package/dist/forms/tree-placement-widget.jsx +0 -160
- package/dist/forms/upload-executor.test.node.js +0 -368
- package/dist/forms/use-form-layout.test.node.js +0 -69
- package/dist/modules/admin-account/components/change-password.jsx +0 -169
- package/dist/modules/admin-account/components/container.jsx +0 -164
- package/dist/modules/admin-account/components/preferences.jsx +0 -148
- package/dist/modules/admin-account/components/theme-switch.jsx +0 -52
- package/dist/modules/admin-account/components/update.jsx +0 -186
- package/dist/modules/admin-permissions/components/inspector.jsx +0 -200
- package/dist/modules/admin-preferences/schemas.test.node.js +0 -48
- package/dist/modules/admin-roles/components/create.jsx +0 -155
- package/dist/modules/admin-roles/components/permissions.jsx +0 -239
- package/dist/modules/admin-roles/components/update.jsx +0 -148
- package/dist/modules/admin-users/components/create.jsx +0 -206
- package/dist/modules/admin-users/components/roles.jsx +0 -126
- package/dist/modules/admin-users/components/set-password.jsx +0 -133
- package/dist/modules/admin-users/components/update.jsx +0 -215
- package/dist/modules/analytics/components/dashboard.jsx +0 -208
- package/dist/modules/analytics/components/dashboard.test.node.js +0 -162
- package/dist/modules/analytics/components/timeseries.jsx +0 -193
- package/dist/modules/auth/components/sign-in-form.jsx +0 -89
- package/dist/modules/auth/safe-redirect.test.node.js +0 -40
- package/dist/modules/auth/sign-in-form-props.test.node.js +0 -7
- package/dist/presentation/group.jsx +0 -26
- package/dist/presentation/row.jsx +0 -23
- package/dist/presentation/tabs.jsx +0 -39
- package/dist/services/admin-services-context.jsx +0 -17
- package/dist/widgets/diff-viewer/diff-modal.jsx +0 -110
- package/dist/widgets/source-locale-badge/source-locale-badge.jsx +0 -28
- package/dist/widgets/status-badge/status-badge.jsx +0 -46
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { useTranslation } from '@byline/i18n/react';
|
|
3
|
-
import cx from 'clsx';
|
|
4
|
-
import { LocalDateTime } from '../fields/local-date-time';
|
|
5
|
-
import styles from './form-renderer.module.css';
|
|
6
|
-
/**
|
|
7
|
-
* The status / metadata strip at the top of a document form — current
|
|
8
|
-
* workflow status (suppressed for single-status workflows), last-modified and
|
|
9
|
-
* created timestamps, and a "published live" indicator with an inline
|
|
10
|
-
* Unpublish action when a previously-published version is still live.
|
|
11
|
-
*/
|
|
12
|
-
export const FormStatusDisplay = ({ disabled = false, initialData, workflowStatuses, publishedVersion, onUnpublish, afterStatusCells, }) => {
|
|
13
|
-
const { t } = useTranslation('byline-admin');
|
|
14
|
-
const statusCode = initialData?.status;
|
|
15
|
-
const statusLabel = workflowStatuses?.find((s) => s.name === statusCode)?.label ?? statusCode;
|
|
16
|
-
// Single-status workflows (e.g. lookups) have no editorial lifecycle —
|
|
17
|
-
// suppress the "Status: …" cell since there is nothing meaningful to convey.
|
|
18
|
-
const showStatusCell = (workflowStatuses?.length ?? 0) > 1;
|
|
19
|
-
return (<div className={cx('byline-form-status', styles.status)}>
|
|
20
|
-
<div className={cx('byline-form-status-meta', styles['status-meta'])}>
|
|
21
|
-
{showStatusCell && (<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
22
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
23
|
-
{t('forms.status.label')}
|
|
24
|
-
</span>
|
|
25
|
-
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
26
|
-
{statusLabel}
|
|
27
|
-
</span>
|
|
28
|
-
{afterStatusCells}
|
|
29
|
-
</div>)}
|
|
30
|
-
{!showStatusCell && afterStatusCells}
|
|
31
|
-
|
|
32
|
-
{initialData?.updatedAt != null && (<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
33
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
34
|
-
{t('forms.status.lastModified')}
|
|
35
|
-
</span>
|
|
36
|
-
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
37
|
-
<LocalDateTime value={initialData.updatedAt}/>
|
|
38
|
-
</span>
|
|
39
|
-
</div>)}
|
|
40
|
-
|
|
41
|
-
{initialData?.createdAt != null && (<div className={cx('byline-form-status-cell', styles['status-cell'])}>
|
|
42
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
43
|
-
{t('forms.status.created')}
|
|
44
|
-
</span>
|
|
45
|
-
<span className={cx('byline-form-status-trunc', styles['status-trunc'])}>
|
|
46
|
-
<LocalDateTime value={initialData.createdAt}/>
|
|
47
|
-
</span>
|
|
48
|
-
</div>)}
|
|
49
|
-
</div>
|
|
50
|
-
|
|
51
|
-
{publishedVersion != null && (<div className={cx('byline-form-status-published', styles['status-published'])}>
|
|
52
|
-
<span className={cx('byline-form-status-muted', styles['status-muted'])}>
|
|
53
|
-
{t('forms.status.publishedLive')}{' '}
|
|
54
|
-
{publishedVersion.updatedAt ? (<span>
|
|
55
|
-
{t('forms.status.publishedOn', { date: new Date(publishedVersion.updatedAt) })}
|
|
56
|
-
</span>) : ('')}
|
|
57
|
-
</span>
|
|
58
|
-
{onUnpublish && (<>
|
|
59
|
-
{' '}
|
|
60
|
-
<button type="button" disabled={disabled} onClick={() => {
|
|
61
|
-
if (!disabled)
|
|
62
|
-
void onUnpublish().catch(() => { });
|
|
63
|
-
}} className={cx('byline-form-status-unpublish', styles['status-unpublish'])}>
|
|
64
|
-
{t('common.actions.unpublish')}
|
|
65
|
-
</button>
|
|
66
|
-
</>)}
|
|
67
|
-
</div>)}
|
|
68
|
-
</div>);
|
|
69
|
-
};
|
|
@@ -1,68 +0,0 @@
|
|
|
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
|
-
* Framework-agnostic navigation guard adapter.
|
|
10
|
-
*
|
|
11
|
-
* Different router frameworks (TanStack Router, Next.js, React Router, etc.)
|
|
12
|
-
* each have their own mechanism for blocking navigation when a form has unsaved
|
|
13
|
-
* changes. This module defines a common interface so that `FormRenderer` can
|
|
14
|
-
* remain framework-independent — the consuming application injects the
|
|
15
|
-
* appropriate adapter via a prop or React context.
|
|
16
|
-
*/
|
|
17
|
-
import { createContext, useContext, useEffect } from 'react';
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Default (no-op) implementation — browser `beforeunload` only
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
/**
|
|
22
|
-
* Fallback navigation guard that only handles the browser's native
|
|
23
|
-
* `beforeunload` event. In-app (client-side) route changes are **not**
|
|
24
|
-
* intercepted — `isBlocked` will never become `true`.
|
|
25
|
-
*
|
|
26
|
-
* This is used when no framework-specific adapter has been provided.
|
|
27
|
-
*/
|
|
28
|
-
export const useBeforeUnloadGuard = (shouldBlock) => {
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
if (!shouldBlock)
|
|
31
|
-
return;
|
|
32
|
-
const handler = (e) => {
|
|
33
|
-
e.preventDefault();
|
|
34
|
-
};
|
|
35
|
-
window.addEventListener('beforeunload', handler);
|
|
36
|
-
return () => window.removeEventListener('beforeunload', handler);
|
|
37
|
-
}, [shouldBlock]);
|
|
38
|
-
return { isBlocked: false, stay: () => { }, proceed: () => { } };
|
|
39
|
-
};
|
|
40
|
-
// ---------------------------------------------------------------------------
|
|
41
|
-
// React context — allows setting the adapter once at the app shell level
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
const NavigationGuardContext = createContext(useBeforeUnloadGuard);
|
|
44
|
-
/**
|
|
45
|
-
* Provide a framework-specific `UseNavigationGuard` hook to all descendant
|
|
46
|
-
* `FormRenderer` instances.
|
|
47
|
-
*
|
|
48
|
-
* ```tsx
|
|
49
|
-
* import { NavigationGuardProvider } from './navigation-guard'
|
|
50
|
-
* import { useTanStackNavigationGuard } from './tanstack-navigation-guard'
|
|
51
|
-
*
|
|
52
|
-
* function App() {
|
|
53
|
-
* return (
|
|
54
|
-
* <NavigationGuardProvider value={useTanStackNavigationGuard}>
|
|
55
|
-
* <Outlet />
|
|
56
|
-
* </NavigationGuardProvider>
|
|
57
|
-
* )
|
|
58
|
-
* }
|
|
59
|
-
* ```
|
|
60
|
-
*/
|
|
61
|
-
export const NavigationGuardProvider = NavigationGuardContext.Provider;
|
|
62
|
-
/**
|
|
63
|
-
* Consume the current `UseNavigationGuard` hook from context.
|
|
64
|
-
* Falls back to `useBeforeUnloadGuard` when no provider is present.
|
|
65
|
-
*/
|
|
66
|
-
export const useNavigationGuardAdapter = () => {
|
|
67
|
-
return useContext(NavigationGuardContext);
|
|
68
|
-
};
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
-
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
-
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
-
*
|
|
6
|
-
* Copyright (c) Infonomic Company Limited
|
|
7
|
-
*/
|
|
8
|
-
import { describe, expect, it } from 'vitest';
|
|
9
|
-
import { get, hasExistingIdTargets, set, setWithResult, toPath } from './nested-path';
|
|
10
|
-
describe('toPath', () => {
|
|
11
|
-
it('parses dot and bracket notation', () => {
|
|
12
|
-
expect(toPath('title')).toEqual(['title']);
|
|
13
|
-
expect(toPath('a.b.c')).toEqual(['a', 'b', 'c']);
|
|
14
|
-
expect(toPath('items[0].title')).toEqual(['items', '0', 'title']);
|
|
15
|
-
expect(toPath('blocks[2].nested[1].field')).toEqual(['blocks', '2', 'nested', '1', 'field']);
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
describe('get', () => {
|
|
19
|
-
const obj = { a: { b: 1, zero: 0, empty: '' }, items: [{ title: 'x' }, { title: 'y' }] };
|
|
20
|
-
it('reads nested, array, and mixed paths', () => {
|
|
21
|
-
expect(get(obj, 'a.b')).toBe(1);
|
|
22
|
-
expect(get(obj, 'items[0].title')).toBe('x');
|
|
23
|
-
expect(get(obj, 'items[1].title')).toBe('y');
|
|
24
|
-
});
|
|
25
|
-
it('selects nested repeating items by stable id', () => {
|
|
26
|
-
const value = {
|
|
27
|
-
content: [
|
|
28
|
-
{
|
|
29
|
-
_id: 'block-b',
|
|
30
|
-
gallery: [
|
|
31
|
-
{ _id: 'image-1', alt: 'one' },
|
|
32
|
-
{ _id: 'image-2', alt: 'two' },
|
|
33
|
-
],
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
};
|
|
37
|
-
expect(get(value, 'content[id=block-b].gallery[id=image-2].alt')).toBe('two');
|
|
38
|
-
expect(get(value, 'content[id=missing].gallery[id=image-2].alt')).toBeUndefined();
|
|
39
|
-
});
|
|
40
|
-
it('preserves falsy values (does not conflate with missing)', () => {
|
|
41
|
-
expect(get(obj, 'a.zero')).toBe(0);
|
|
42
|
-
expect(get(obj, 'a.empty')).toBe('');
|
|
43
|
-
});
|
|
44
|
-
it('returns undefined for missing paths or nullish roots', () => {
|
|
45
|
-
expect(get(obj, 'a.x')).toBeUndefined();
|
|
46
|
-
expect(get(obj, 'missing.deep.path')).toBeUndefined();
|
|
47
|
-
expect(get(obj, 'items[5].title')).toBeUndefined();
|
|
48
|
-
expect(get(null, 'a.b')).toBeUndefined();
|
|
49
|
-
expect(get(undefined, 'a.b')).toBeUndefined();
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
describe('set', () => {
|
|
53
|
-
it('sets simple and nested values, creating intermediate objects', () => {
|
|
54
|
-
const o = {};
|
|
55
|
-
set(o, 'a.b.c', 1);
|
|
56
|
-
expect(o).toEqual({ a: { b: { c: 1 } } });
|
|
57
|
-
});
|
|
58
|
-
it('creates arrays for numeric index segments', () => {
|
|
59
|
-
const o = {};
|
|
60
|
-
set(o, 'items[0].title', 'x');
|
|
61
|
-
expect(Array.isArray(o.items)).toBe(true);
|
|
62
|
-
expect(o.items[0]).toEqual({ title: 'x' });
|
|
63
|
-
});
|
|
64
|
-
it('handles deep, mixed array/object paths', () => {
|
|
65
|
-
const o = {};
|
|
66
|
-
set(o, 'blocks[1].nested[0].field', 42);
|
|
67
|
-
expect(Array.isArray(o.blocks)).toBe(true);
|
|
68
|
-
expect(Array.isArray(o.blocks[1].nested)).toBe(true);
|
|
69
|
-
expect(o.blocks[1].nested[0].field).toBe(42);
|
|
70
|
-
});
|
|
71
|
-
it('overwrites existing values and preserves siblings', () => {
|
|
72
|
-
const o = { a: { b: 1, keep: 2 } };
|
|
73
|
-
set(o, 'a.b', 9);
|
|
74
|
-
expect(o).toEqual({ a: { b: 9, keep: 2 } });
|
|
75
|
-
});
|
|
76
|
-
it('writes into a pre-existing array element without clobbering the array', () => {
|
|
77
|
-
const o = { items: [{ title: 'x' }, { title: 'y' }] };
|
|
78
|
-
set(o, 'items[1].title', 'z');
|
|
79
|
-
expect(o.items[1].title).toBe('z');
|
|
80
|
-
expect(o.items[0].title).toBe('x');
|
|
81
|
-
expect(Array.isArray(o.items)).toBe(true);
|
|
82
|
-
});
|
|
83
|
-
it('writes through nested stable-id selectors without changing array order', () => {
|
|
84
|
-
const o = {
|
|
85
|
-
content: [
|
|
86
|
-
{ _id: 'a', gallery: [{ _id: 'x', alt: 'keep' }] },
|
|
87
|
-
{ _id: 'b', gallery: [{ _id: 'y', alt: 'old' }] },
|
|
88
|
-
],
|
|
89
|
-
};
|
|
90
|
-
set(o, 'content[id=b].gallery[id=y].alt', 'new');
|
|
91
|
-
expect(o.content.map((item) => item._id)).toEqual(['a', 'b']);
|
|
92
|
-
expect(o.content[0]?.gallery[0]?.alt).toBe('keep');
|
|
93
|
-
expect(o.content[1]?.gallery[0]?.alt).toBe('new');
|
|
94
|
-
});
|
|
95
|
-
it('does not create a ghost item for an unknown stable id', () => {
|
|
96
|
-
const o = { items: [{ _id: 'a', title: 'keep' }] };
|
|
97
|
-
expect(setWithResult(o, 'items[id=gone].title', 'new')).toBe(false);
|
|
98
|
-
expect(o).toEqual({ items: [{ _id: 'a', title: 'keep' }] });
|
|
99
|
-
const missingContainer = { group: {} };
|
|
100
|
-
expect(setWithResult(missingContainer, 'group.items[id=gone].title', 'new')).toBe(false);
|
|
101
|
-
expect(missingContainer).toEqual({ group: {} });
|
|
102
|
-
});
|
|
103
|
-
it('returns the mutated root', () => {
|
|
104
|
-
const o = {};
|
|
105
|
-
expect(set(o, 'x', 1)).toBe(o);
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
describe('hasExistingIdTargets', () => {
|
|
109
|
-
const value = { items: [{ _id: 'a', nested: [{ _id: 'b' }] }] };
|
|
110
|
-
it('accepts live nested identities and rejects removed ones', () => {
|
|
111
|
-
expect(hasExistingIdTargets(value, 'items[id=a].nested[id=b].title')).toBe(true);
|
|
112
|
-
expect(hasExistingIdTargets(value, 'items[id=a].nested[id=gone].title')).toBe(false);
|
|
113
|
-
expect(hasExistingIdTargets(value, 'title')).toBe(true);
|
|
114
|
-
});
|
|
115
|
-
});
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
/**
|
|
3
|
-
* This Source Code is subject to the terms of the Mozilla Public
|
|
4
|
-
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
-
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
6
|
-
*
|
|
7
|
-
* Copyright (c) Infonomic Company Limited
|
|
8
|
-
*/
|
|
9
|
-
import { useCallback, useMemo } from 'react';
|
|
10
|
-
import { slugify } from '@byline/core';
|
|
11
|
-
import { useTranslation } from '@byline/i18n/react';
|
|
12
|
-
import { Input, Label } from '@byline/ui/react';
|
|
13
|
-
import cx from 'clsx';
|
|
14
|
-
import { useFieldValue, useFormContext, useSystemPath } from './form-context';
|
|
15
|
-
import styles from './path-widget.module.css';
|
|
16
|
-
/**
|
|
17
|
-
* Coerce an arbitrary source-field value (string, Date, or other) into
|
|
18
|
-
* a string suitable for slugification. Mirrors the lifecycle's coercion
|
|
19
|
-
* so the live preview matches what the server will store.
|
|
20
|
-
*/
|
|
21
|
-
function coerceToString(value) {
|
|
22
|
-
if (value == null)
|
|
23
|
-
return '';
|
|
24
|
-
if (value instanceof Date)
|
|
25
|
-
return value.toISOString();
|
|
26
|
-
return String(value);
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* System-managed `path` widget.
|
|
30
|
-
*
|
|
31
|
-
* Edits the path stored in `byline_document_paths` for the current
|
|
32
|
-
* (document, locale) row. Displays the current persisted/overridden
|
|
33
|
-
* value as an editable input.
|
|
34
|
-
* In create mode, when the user hasn't supplied an override, the input
|
|
35
|
-
* shows the live-derived preview (slugified `useAsPath` source field) as
|
|
36
|
-
* a placeholder so the user sees what will be saved. The "Regenerate"
|
|
37
|
-
* action explicitly writes the current live preview into the override
|
|
38
|
-
* slot so the user can re-anchor a path against the source field after
|
|
39
|
-
* editing the title.
|
|
40
|
-
*
|
|
41
|
-
* Stable override handles: `.byline-form-path`, `.byline-form-path-header`,
|
|
42
|
-
* `.byline-form-path-regenerate`.
|
|
43
|
-
*/
|
|
44
|
-
export const PathWidget = ({ useAsPath, collectionPath, defaultLocale, activeLocale, mode, slugifier, sourceLocked = false, disabled = false, }) => {
|
|
45
|
-
const { setSystemPath } = useFormContext();
|
|
46
|
-
const { t } = useTranslation('byline-admin');
|
|
47
|
-
const systemPath = useSystemPath();
|
|
48
|
-
const sourceValue = useFieldValue(useAsPath ?? '');
|
|
49
|
-
// The installation slugifier, or the built-in default. Server-side path
|
|
50
|
-
// derivation uses `ServerConfig.slugifier`; this must be the same function
|
|
51
|
-
// (registered via `AdminConfig.slugifier`) or the preview will disagree
|
|
52
|
-
// with what the server persists.
|
|
53
|
-
const runSlugify = slugifier ?? slugify;
|
|
54
|
-
// Phase 1: paths are written/edited only under the default content
|
|
55
|
-
// locale. When editing a translation, the widget locks down — the
|
|
56
|
-
// input is read-only, the Regenerate action is suppressed, and a
|
|
57
|
-
// helpText line explains why.
|
|
58
|
-
const isReadOnly = activeLocale !== defaultLocale;
|
|
59
|
-
// Live preview — what the server would derive from the current source
|
|
60
|
-
// field value if no override were set. Used as placeholder in create
|
|
61
|
-
// mode and as the target of the "Regenerate" action.
|
|
62
|
-
const livePreview = useMemo(() => {
|
|
63
|
-
// A locked source (server-assigned counter / read-only field) can't be
|
|
64
|
-
// previewed or regenerated from the form — suppress the derivation.
|
|
65
|
-
if (!useAsPath || sourceLocked)
|
|
66
|
-
return '';
|
|
67
|
-
const asString = coerceToString(sourceValue);
|
|
68
|
-
if (asString.length === 0)
|
|
69
|
-
return '';
|
|
70
|
-
return runSlugify(asString, { locale: defaultLocale, collectionPath });
|
|
71
|
-
}, [useAsPath, sourceLocked, sourceValue, defaultLocale, collectionPath, runSlugify]);
|
|
72
|
-
const inputValue = systemPath ?? '';
|
|
73
|
-
const handleChange = useCallback((next) => {
|
|
74
|
-
if (disabled)
|
|
75
|
-
return;
|
|
76
|
-
// Empty string clears the override — server falls back to derive
|
|
77
|
-
// (create) or sticky (update).
|
|
78
|
-
setSystemPath(next.length === 0 ? null : next);
|
|
79
|
-
}, [disabled, setSystemPath]);
|
|
80
|
-
const handleRegenerate = useCallback(() => {
|
|
81
|
-
if (!disabled && livePreview.length > 0) {
|
|
82
|
-
setSystemPath(livePreview);
|
|
83
|
-
}
|
|
84
|
-
}, [disabled, livePreview, setSystemPath]);
|
|
85
|
-
// Validate live: if the typed value differs from its slugified form,
|
|
86
|
-
// surface an inline hint without blocking input (mirrors the previous
|
|
87
|
-
// field-hook advisory behaviour).
|
|
88
|
-
const formatted = useMemo(() => {
|
|
89
|
-
if (inputValue.length === 0)
|
|
90
|
-
return '';
|
|
91
|
-
return runSlugify(inputValue, { locale: defaultLocale, collectionPath });
|
|
92
|
-
}, [inputValue, defaultLocale, collectionPath, runSlugify]);
|
|
93
|
-
const validationHint = inputValue.length > 0 && formatted !== inputValue
|
|
94
|
-
? t('pathWidget.suggestedHint', { formatted })
|
|
95
|
-
: undefined;
|
|
96
|
-
// When read-only, replace the live validation hint with a fixed
|
|
97
|
-
// explanatory line so editors understand why the field is locked.
|
|
98
|
-
const readOnlyHint = isReadOnly
|
|
99
|
-
? t('pathWidget.readOnlyHint', { locale: defaultLocale })
|
|
100
|
-
: undefined;
|
|
101
|
-
const hint = readOnlyHint ?? validationHint;
|
|
102
|
-
const placeholder = !isReadOnly && mode === 'create' && livePreview.length > 0
|
|
103
|
-
? t('pathWidget.willBeSavedAs', { preview: livePreview })
|
|
104
|
-
: undefined;
|
|
105
|
-
// Screen-reader description. The input's base purpose ("System-managed
|
|
106
|
-
// URL path") plus whichever of the visible hints (placeholder preview
|
|
107
|
-
// in create mode, "Suggested" validation hint, or read-only explainer)
|
|
108
|
-
// currently applies. The visible helpText/placeholder cover sighted
|
|
109
|
-
// users; this element makes the same information addressable via
|
|
110
|
-
// aria-describedby for AT.
|
|
111
|
-
const srDescription = [t('pathWidget.srDescription'), placeholder, hint].filter(Boolean).join(' ');
|
|
112
|
-
const showRegenerate = !isReadOnly && useAsPath && livePreview.length > 0 && livePreview !== systemPath;
|
|
113
|
-
return (<div className="byline-form-path">
|
|
114
|
-
<div className={cx('byline-form-path-header', styles.header)}>
|
|
115
|
-
<Label id="system-path-label" htmlFor="system-path" label={t('pathWidget.label')}/>
|
|
116
|
-
{showRegenerate && (<button type="button" onClick={handleRegenerate} className={cx('byline-form-path-regenerate', styles.regenerate)} disabled={disabled} aria-label={t('pathWidget.regenerateAriaLabel', { field: useAsPath })}>
|
|
117
|
-
{t('pathWidget.regenerateButton', { field: useAsPath })}
|
|
118
|
-
</button>)}
|
|
119
|
-
</div>
|
|
120
|
-
<Input disabled={disabled} id="system-path" name="__systemPath__" value={inputValue} placeholder={placeholder} onChange={(e) => handleChange(e.target.value)} helpText={hint} readOnly={isReadOnly} aria-describedby="system-path-description"/>
|
|
121
|
-
<span id="system-path-description" className={cx('byline-form-path-sr-only', styles['sr-only'])}>
|
|
122
|
-
{srDescription}
|
|
123
|
-
</span>
|
|
124
|
-
</div>);
|
|
125
|
-
};
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { deletePendingUploadsUnderPath } from './pending-uploads';
|
|
3
|
-
describe('deletePendingUploadsUnderPath', () => {
|
|
4
|
-
it('removes and revokes only uploads beneath the removed item', () => {
|
|
5
|
-
const uploads = new Map([
|
|
6
|
-
['content[id=a].image', { previewUrl: 'blob:a' }],
|
|
7
|
-
['content[id=b].image', { previewUrl: 'blob:b' }],
|
|
8
|
-
['content[id=b].gallery[id=x].image', { previewUrl: 'blob:nested' }],
|
|
9
|
-
]);
|
|
10
|
-
const revoke = vi.fn();
|
|
11
|
-
expect(deletePendingUploadsUnderPath(uploads, 'content[id=b]', revoke)).toBe(true);
|
|
12
|
-
expect([...uploads.keys()]).toEqual(['content[id=a].image']);
|
|
13
|
-
expect(revoke.mock.calls).toEqual([['blob:b'], ['blob:nested']]);
|
|
14
|
-
});
|
|
15
|
-
it('reports when no pending upload matched', () => {
|
|
16
|
-
const uploads = new Map([['content[id=a].image', { previewUrl: 'blob:a' }]]);
|
|
17
|
-
expect(deletePendingUploadsUnderPath(uploads, 'content[id=b]', vi.fn())).toBe(false);
|
|
18
|
-
});
|
|
19
|
-
});
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from './repeating-items';
|
|
3
|
-
describe('repeatingItemPath', () => {
|
|
4
|
-
it('uses stable storage identity when available', () => {
|
|
5
|
-
expect(repeatingItemPath('content', { _id: 'block-b' }, 1)).toBe('content[id=block-b]');
|
|
6
|
-
});
|
|
7
|
-
it('falls back to position for id-less or path-unsafe identities', () => {
|
|
8
|
-
expect(repeatingItemPath('content', {}, 1)).toBe('content[1]');
|
|
9
|
-
expect(repeatingItemPath('content', { _id: 'unsafe.id' }, 1)).toBe('content[1]');
|
|
10
|
-
expect(repeatingItemPath('content', { _id: null }, 1)).toBe('content[1]');
|
|
11
|
-
expect(repeatingItemId({ _id: 42 })).toBeUndefined();
|
|
12
|
-
});
|
|
13
|
-
});
|
|
14
|
-
describe('moveRepeatingItems', () => {
|
|
15
|
-
const ids = (items) => items.map((item) => item._id);
|
|
16
|
-
it('keeps consecutive moves aligned with the current form-store order', () => {
|
|
17
|
-
const first = moveRepeatingItems([{ _id: 'a' }, { _id: 'b' }, { _id: 'c' }], 2, 0);
|
|
18
|
-
expect(first?.itemId).toBe('c');
|
|
19
|
-
expect(ids(first?.items ?? [])).toEqual(['c', 'a', 'b']);
|
|
20
|
-
const second = moveRepeatingItems(first?.items ?? [], 2, 1);
|
|
21
|
-
expect(second?.itemId).toBe('b');
|
|
22
|
-
expect(ids(second?.items ?? [])).toEqual(['c', 'b', 'a']);
|
|
23
|
-
});
|
|
24
|
-
it('retains the positional patch fallback for id-less items', () => {
|
|
25
|
-
const moved = moveRepeatingItems([{ value: 'a' }, { value: 'b' }], 1, 0);
|
|
26
|
-
expect(moved?.itemId).toBe('1');
|
|
27
|
-
expect(moved?.items.map((item) => item.value)).toEqual(['b', 'a']);
|
|
28
|
-
});
|
|
29
|
-
});
|