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