@byline/admin 6.8.0 → 6.10.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/dist/fields/blocks/blocks-field.js +33 -3
- package/dist/fields/blocks/blocks-field.module.js +1 -0
- package/dist/fields/blocks/blocks-field_module.css +19 -0
- package/dist/forms/form-page-chrome.d.ts +3 -2
- package/dist/forms/form-page-chrome.js +13 -1
- package/dist/forms/form-renderer.d.ts +2 -1
- package/dist/forms/form-renderer.js +23 -17
- package/dist/forms/unavailable-blocks.d.ts +15 -0
- package/dist/forms/unavailable-blocks.js +38 -0
- package/package.json +11 -11
- package/src/fields/blocks/blocks-field.module.css +23 -0
- package/src/fields/blocks/blocks-field.tsx +26 -2
- package/src/forms/form-page-chrome.tsx +19 -1
- package/src/forms/form-renderer-capabilities.test.tsx +6 -6
- package/src/forms/form-renderer.tsx +24 -15
- package/src/forms/unavailable-blocks.test.tsx +152 -0
- package/src/forms/unavailable-blocks.tsx +58 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { getAdminConfig } from "@byline/core";
|
|
4
4
|
import { useTranslation } from "@byline/i18n/react";
|
|
5
|
-
import { Card, CloseIcon, DraggableSortable, ErrorText, IconButton, Modal, PlusIcon, moveItem } from "@byline/ui/react";
|
|
5
|
+
import { Card, CloseIcon, DraggableSortable, ErrorText, IconButton, Modal, PlusIcon, WarningIcon, moveItem } from "@byline/ui/react";
|
|
6
6
|
import clsx from "clsx";
|
|
7
7
|
import { defaultScalarForField } from "../field-helpers.js";
|
|
8
8
|
import { GroupField } from "../group/group-field.js";
|
|
@@ -11,10 +11,12 @@ import { useFieldError, useFormContext } from "../../forms/form-context.js";
|
|
|
11
11
|
import { useScopedDomId } from "../../forms/form-dom-scope.js";
|
|
12
12
|
import { hasExistingIdTargets } from "../../forms/nested-path.js";
|
|
13
13
|
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from "../../forms/repeating-items.js";
|
|
14
|
+
import { useUnavailableBlocks } from "../../forms/unavailable-blocks.js";
|
|
14
15
|
import blocks_field_module from "./blocks-field.module.js";
|
|
15
16
|
const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
|
|
16
17
|
const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } = useFormContext();
|
|
17
18
|
const { t } = useTranslation('byline-admin');
|
|
19
|
+
const unavailableBlocks = useUnavailableBlocks(path);
|
|
18
20
|
const fieldError = useFieldError(path);
|
|
19
21
|
const htmlId = useScopedDomId(path);
|
|
20
22
|
const [items, setItems] = useState([]);
|
|
@@ -170,7 +172,35 @@ const BlocksField = ({ field, defaultValue, path, contentLocale })=>{
|
|
|
170
172
|
onDragEnd: handleDragEnd,
|
|
171
173
|
className: clsx('byline-field-blocks-stack', blocks_field_module.stack),
|
|
172
174
|
children: [
|
|
173
|
-
|
|
175
|
+
Array.from({
|
|
176
|
+
length: items.length + 1
|
|
177
|
+
}, (_, index)=>/*#__PURE__*/ jsxs(Fragment, {
|
|
178
|
+
children: [
|
|
179
|
+
unavailableBlocks.map((issue, removedBefore)=>Math.min(issue.storedIndex - removedBefore, items.length) === index ? /*#__PURE__*/ jsxs("div", {
|
|
180
|
+
role: "note",
|
|
181
|
+
className: clsx('byline-field-blocks-unavailable', blocks_field_module.unavailable),
|
|
182
|
+
children: [
|
|
183
|
+
/*#__PURE__*/ jsx(WarningIcon, {
|
|
184
|
+
width: "24px",
|
|
185
|
+
height: "24px"
|
|
186
|
+
}),
|
|
187
|
+
/*#__PURE__*/ jsxs("div", {
|
|
188
|
+
children: [
|
|
189
|
+
/*#__PURE__*/ jsx("strong", {
|
|
190
|
+
children: t('fields.blocks.unavailableTitle', {
|
|
191
|
+
blockType: issue.blockType
|
|
192
|
+
})
|
|
193
|
+
}),
|
|
194
|
+
/*#__PURE__*/ jsx("p", {
|
|
195
|
+
children: t('fields.blocks.unavailableBody')
|
|
196
|
+
})
|
|
197
|
+
]
|
|
198
|
+
})
|
|
199
|
+
]
|
|
200
|
+
}, `${issue.storedIndex}-${issue.blockType}`) : null),
|
|
201
|
+
index < items.length && renderItem(items[index], index)
|
|
202
|
+
]
|
|
203
|
+
}, items[index]?.id ?? 'end')),
|
|
174
204
|
/*#__PURE__*/ jsxs("div", {
|
|
175
205
|
className: clsx('byline-field-blocks-add-row', blocks_field_module["add-row"]),
|
|
176
206
|
children: [
|
|
@@ -10,6 +10,25 @@
|
|
|
10
10
|
display: flex;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
:is(.unavailable-B9h_EU, .byline-field-blocks-unavailable) {
|
|
14
|
+
align-items: flex-start;
|
|
15
|
+
gap: var(--spacing-8);
|
|
16
|
+
padding: var(--spacing-16);
|
|
17
|
+
border: 1px solid var(--stroke-warning);
|
|
18
|
+
border-radius: var(--border-radius-md);
|
|
19
|
+
background-color: oklch(from var(--fill-warning-weak) l c h / calc(alpha * .5));
|
|
20
|
+
color: var(--foreground);
|
|
21
|
+
display: flex;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
:is(.unavailable-B9h_EU > :first-child, .byline-field-blocks-unavailable > :first-child) {
|
|
25
|
+
flex-shrink: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
:is(.unavailable-B9h_EU p, .byline-field-blocks-unavailable p) {
|
|
29
|
+
margin: var(--spacing-4) 0 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
:is(.add-row-gEqjM_, .byline-field-blocks-add-row) {
|
|
14
33
|
color: var(--muted);
|
|
15
34
|
align-items: center;
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* visible at its call site.
|
|
15
15
|
*/
|
|
16
16
|
import type { ReactNode, RefObject } from 'react';
|
|
17
|
-
import type { SlugifierFn, StructuralMutationReceipt, WorkflowStatus } from '@byline/core';
|
|
17
|
+
import type { SlugifierFn, StructuralMutationReceipt, UnavailableBlock, WorkflowStatus } from '@byline/core';
|
|
18
18
|
import { type DocumentActionsLocaleOption } from './document-actions.js';
|
|
19
19
|
import { type UseScheduledPublicationReturn } from './scheduled-publication-control.js';
|
|
20
20
|
import type { PublishedVersionInfo } from './form-renderer.js';
|
|
@@ -78,8 +78,9 @@ export interface FormConcurrencyNoticesProps {
|
|
|
78
78
|
onDiscardRequested: () => void;
|
|
79
79
|
scheduling: UseScheduledPublicationReturn;
|
|
80
80
|
restoreWarnings?: string[];
|
|
81
|
+
unavailableBlocks?: UnavailableBlock[];
|
|
81
82
|
}
|
|
82
|
-
export declare const FormConcurrencyNotices: ({ disabled, mutationIssue, scheduledPublicationsNeedReconfirmation, scheduledPublicationsHref, warningRef, discarding, reloadFailed, onDiscardRequested, scheduling, restoreWarnings, }: FormConcurrencyNoticesProps) => ReactNode;
|
|
83
|
+
export declare const FormConcurrencyNotices: ({ disabled, mutationIssue, scheduledPublicationsNeedReconfirmation, scheduledPublicationsHref, warningRef, discarding, reloadFailed, onDiscardRequested, scheduling, restoreWarnings, unavailableBlocks, }: FormConcurrencyNoticesProps) => ReactNode;
|
|
83
84
|
export interface FormSidebarWidgetsProps {
|
|
84
85
|
/** Mutations blocked or a discard in flight. */
|
|
85
86
|
disabled: boolean;
|
|
@@ -132,7 +132,7 @@ const FormStatusBar = ({ disabled, initialData, workflowStatuses, publishedVersi
|
|
|
132
132
|
]
|
|
133
133
|
});
|
|
134
134
|
};
|
|
135
|
-
const FormConcurrencyNotices = ({ disabled, mutationIssue, scheduledPublicationsNeedReconfirmation, scheduledPublicationsHref, warningRef, discarding, reloadFailed, onDiscardRequested, scheduling, restoreWarnings })=>{
|
|
135
|
+
const FormConcurrencyNotices = ({ disabled, mutationIssue, scheduledPublicationsNeedReconfirmation, scheduledPublicationsHref, warningRef, discarding, reloadFailed, onDiscardRequested, scheduling, restoreWarnings, unavailableBlocks })=>{
|
|
136
136
|
const { t } = useTranslation('byline-admin');
|
|
137
137
|
return /*#__PURE__*/ jsxs(Fragment, {
|
|
138
138
|
children: [
|
|
@@ -189,6 +189,18 @@ const FormConcurrencyNotices = ({ disabled, mutationIssue, scheduledPublications
|
|
|
189
189
|
onCancel: scheduling.cancel
|
|
190
190
|
}),
|
|
191
191
|
scheduling.modal,
|
|
192
|
+
unavailableBlocks && unavailableBlocks.length > 0 && /*#__PURE__*/ jsx(Alert, {
|
|
193
|
+
className: "m-0 mt-4",
|
|
194
|
+
intent: "warning",
|
|
195
|
+
icon: true,
|
|
196
|
+
close: false,
|
|
197
|
+
title: t('forms.unavailableBlocks.title'),
|
|
198
|
+
children: /*#__PURE__*/ jsx("p", {
|
|
199
|
+
children: t('forms.unavailableBlocks.body', {
|
|
200
|
+
count: unavailableBlocks.length
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
}),
|
|
192
204
|
restoreWarnings && restoreWarnings.length > 0 && /*#__PURE__*/ jsxs(Alert, {
|
|
193
205
|
className: "m-0 mt-4",
|
|
194
206
|
intent: "warning",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
import { type ReactNode } from 'react';
|
|
9
|
-
import type { AdminResourceConfig, Field, WorkflowStatus } from '@byline/core';
|
|
9
|
+
import type { AdminResourceConfig, Field, UnavailableBlock, WorkflowStatus } from '@byline/core';
|
|
10
10
|
import { type ScheduledPublicationInfo, type SchedulePublicationInput } from './scheduled-publication-control.js';
|
|
11
11
|
import { type SystemFieldsSubmitPayload } from './use-form-submission.js';
|
|
12
12
|
import type { DocumentActionsLocaleOption } from './document-actions.js';
|
|
@@ -142,6 +142,7 @@ export interface FormRendererProps {
|
|
|
142
142
|
* them with the new shape.
|
|
143
143
|
*/
|
|
144
144
|
restoreWarnings?: string[];
|
|
145
|
+
unavailableBlocks?: UnavailableBlock[];
|
|
145
146
|
/**
|
|
146
147
|
* Default content locale used when no `initialLocale` is supplied and as the
|
|
147
148
|
* fallback inside `PathWidget`. Hosts typically pass their app-wide
|
|
@@ -13,9 +13,10 @@ import form_renderer_module from "./form-renderer.module.js";
|
|
|
13
13
|
import { useNavigationGuardAdapter } from "./navigation-guard.js";
|
|
14
14
|
import { useScheduledPublication } from "./scheduled-publication-control.js";
|
|
15
15
|
import { computeStatusTransitions } from "./status-transitions.js";
|
|
16
|
+
import { UnavailableBlocksProvider } from "./unavailable-blocks.js";
|
|
16
17
|
import { useDocumentReloadRecovery } from "./use-document-reload-recovery.js";
|
|
17
18
|
import { useFormSubmission } from "./use-form-submission.js";
|
|
18
|
-
const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRevision, onMutationError, onTreeMutationCommitted, scheduledPublicationsNeedReconfirmation = false, scheduledPublicationsHref, onReloadDocument, fields, onSubmit, onCancel, onStatusChange, onUnpublish, scheduledPublication, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, showPath = true, heading, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, _activeTabBySet, _onTabChange })=>{
|
|
19
|
+
const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRevision, onMutationError, onTreeMutationCommitted, scheduledPublicationsNeedReconfirmation = false, scheduledPublicationsHref, onReloadDocument, fields, onSubmit, onCancel, onStatusChange, onUnpublish, scheduledPublication, onSchedulePublication, onConfirmScheduledPublication, onCancelScheduledPublication, onDelete, onDuplicate, onCopyToLocale, onDeleteLocale, contentLocales, nextStatus, workflowStatuses, publishedVersion, initialData, adminConfig, useAsTitle, useAsPath, showPath = true, heading, advertiseLocales, tree, headingLabel, headerSlot, collectionPath, initialLocale, onLocaleChange, defaultLocale = 'en', useNavigationGuard: useNavigationGuardProp, restoreWarnings, unavailableBlocks, _activeTabBySet, _onTabChange })=>{
|
|
19
20
|
const { hasChanges: hasChangesFn, subscribeMeta } = useFormContext();
|
|
20
21
|
const { t } = useTranslation('byline-admin');
|
|
21
22
|
const [hasChanges, setHasChanges] = useState(hasChangesFn());
|
|
@@ -177,7 +178,8 @@ const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRe
|
|
|
177
178
|
reloadFailed: reloadFailed,
|
|
178
179
|
onDiscardRequested: requestDiscard,
|
|
179
180
|
scheduling: scheduling,
|
|
180
|
-
restoreWarnings: restoreWarnings
|
|
181
|
+
restoreWarnings: restoreWarnings,
|
|
182
|
+
unavailableBlocks: unavailableBlocks
|
|
181
183
|
}),
|
|
182
184
|
/*#__PURE__*/ jsx(FormLayout, {
|
|
183
185
|
fields: fields,
|
|
@@ -233,20 +235,24 @@ const FormContent = ({ mode, mutationIssue, mutationsBlocked = false, observedRe
|
|
|
233
235
|
const FormRenderer = (props)=>{
|
|
234
236
|
const { mode, initialData, initialLocale, collectionPath } = props;
|
|
235
237
|
const savedTabsRef = useRef({});
|
|
236
|
-
return /*#__PURE__*/ jsx(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
238
|
+
return /*#__PURE__*/ jsx(UnavailableBlocksProvider, {
|
|
239
|
+
issues: props.unavailableBlocks,
|
|
240
|
+
fields: initialData?.fields ?? initialData ?? {},
|
|
241
|
+
children: /*#__PURE__*/ jsx(FormProvider, {
|
|
242
|
+
initialData: initialData,
|
|
243
|
+
documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : null,
|
|
244
|
+
collectionPath: collectionPath ?? null,
|
|
245
|
+
children: /*#__PURE__*/ jsx(FormContent, {
|
|
246
|
+
...props,
|
|
247
|
+
_activeTabBySet: savedTabsRef.current,
|
|
248
|
+
_onTabChange: (tabSetName, tabName)=>{
|
|
249
|
+
savedTabsRef.current = {
|
|
250
|
+
...savedTabsRef.current,
|
|
251
|
+
[tabSetName]: tabName
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
}, `${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`)
|
|
256
|
+
});
|
|
251
257
|
};
|
|
252
258
|
export { FormRenderer };
|
|
@@ -0,0 +1,15 @@
|
|
|
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 type { ReactNode } from 'react';
|
|
9
|
+
import { type UnavailableBlock } from '@byline/core';
|
|
10
|
+
export declare function UnavailableBlocksProvider({ issues, fields, children, }: {
|
|
11
|
+
issues?: readonly UnavailableBlock[];
|
|
12
|
+
fields: Record<string, any>;
|
|
13
|
+
children: ReactNode;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
15
|
+
export declare function useUnavailableBlocks(path: string): UnavailableBlock[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useMemo } from "react";
|
|
3
|
+
import { parseInstancePath } from "@byline/core";
|
|
4
|
+
import { repeatingItemPath } from "./repeating-items.js";
|
|
5
|
+
const UnavailableBlocksContext = /*#__PURE__*/ createContext([]);
|
|
6
|
+
function bindUnavailableBlocks(issues, fields) {
|
|
7
|
+
return issues.map((issue)=>{
|
|
8
|
+
const parsed = parseInstancePath(issue.fieldPath);
|
|
9
|
+
if (!parsed.ok) return issue;
|
|
10
|
+
let value = fields;
|
|
11
|
+
let path = '';
|
|
12
|
+
for (const segment of parsed.segments)if ('field' === segment.kind) {
|
|
13
|
+
path = path ? `${path}.${segment.name}` : segment.name;
|
|
14
|
+
value = value?.[segment.name];
|
|
15
|
+
} else if ('index' === segment.kind) {
|
|
16
|
+
value = value?.[segment.index];
|
|
17
|
+
path = repeatingItemPath(path, value, segment.index);
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
...issue,
|
|
21
|
+
fieldPath: path
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function UnavailableBlocksProvider({ issues, fields, children }) {
|
|
26
|
+
const bound = useMemo(()=>bindUnavailableBlocks(issues ?? [], fields), [
|
|
27
|
+
issues,
|
|
28
|
+
fields
|
|
29
|
+
]);
|
|
30
|
+
return /*#__PURE__*/ jsx(UnavailableBlocksContext.Provider, {
|
|
31
|
+
value: bound,
|
|
32
|
+
children: children
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function useUnavailableBlocks(path) {
|
|
36
|
+
return useContext(UnavailableBlocksContext).filter((issue)=>issue.fieldPath === path);
|
|
37
|
+
}
|
|
38
|
+
export { UnavailableBlocksProvider, useUnavailableBlocks };
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/admin",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "6.
|
|
5
|
+
"version": "6.10.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=22.22.2"
|
|
8
8
|
},
|
|
@@ -169,12 +169,12 @@
|
|
|
169
169
|
"src/"
|
|
170
170
|
],
|
|
171
171
|
"dependencies": {
|
|
172
|
-
"@byline/analytics": "6.
|
|
173
|
-
"@byline/analytics-agent": "6.
|
|
174
|
-
"@byline/auth": "6.
|
|
175
|
-
"@byline/core": "6.
|
|
176
|
-
"@byline/i18n": "6.
|
|
177
|
-
"@byline/ui": "6.
|
|
172
|
+
"@byline/analytics": "6.10.0",
|
|
173
|
+
"@byline/analytics-agent": "6.10.0",
|
|
174
|
+
"@byline/auth": "6.10.0",
|
|
175
|
+
"@byline/core": "6.10.0",
|
|
176
|
+
"@byline/i18n": "6.10.0",
|
|
177
|
+
"@byline/ui": "6.10.0",
|
|
178
178
|
"@codemirror/commands": "^6.11.1",
|
|
179
179
|
"@codemirror/lang-css": "^6.3.1",
|
|
180
180
|
"@codemirror/lang-html": "^6.4.12",
|
|
@@ -186,13 +186,13 @@
|
|
|
186
186
|
"@codemirror/lang-yaml": "^6.1.3",
|
|
187
187
|
"@codemirror/language": "^6.13.1",
|
|
188
188
|
"@codemirror/state": "^6.7.6",
|
|
189
|
-
"@codemirror/view": "^6.43.
|
|
189
|
+
"@codemirror/view": "^6.43.15",
|
|
190
190
|
"@lezer/highlight": "^1.2.5",
|
|
191
191
|
"@simplewebauthn/browser": "^14.0.0",
|
|
192
192
|
"@simplewebauthn/server": "^14.0.3",
|
|
193
193
|
"@tanstack/react-form-start": "^1.33.5",
|
|
194
194
|
"clsx": "^2.1.1",
|
|
195
|
-
"jose": "^6.2.
|
|
195
|
+
"jose": "^6.2.13",
|
|
196
196
|
"react-diff-viewer-continued": "^4.4.0",
|
|
197
197
|
"uuid": "^14.0.3",
|
|
198
198
|
"zod": "^4.6.7"
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"react-dom": "^19.0.0"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@biomejs/biome": "2.
|
|
205
|
+
"@biomejs/biome": "2.6.2",
|
|
206
206
|
"@rsbuild/plugin-react": "^2.1.1",
|
|
207
207
|
"@rslib/core": "^1.0.3",
|
|
208
208
|
"@types/node": "^26.6.5",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"react-dom": "^19.3.1",
|
|
215
215
|
"typescript": "^7.0.2",
|
|
216
216
|
"typescript-plugin-css-modules": "^5.2.0",
|
|
217
|
-
"vitest": "^5.0.
|
|
217
|
+
"vitest": "^5.0.5"
|
|
218
218
|
},
|
|
219
219
|
"publishConfig": {
|
|
220
220
|
"access": "public",
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* .byline-field-blocks — outer wrapper
|
|
6
6
|
* .byline-field-blocks-title — block-list label heading
|
|
7
7
|
* .byline-field-blocks-stack — vertical stack of blocks + add row
|
|
8
|
+
* .byline-field-blocks-unavailable — display-only unavailable-block warning
|
|
8
9
|
* .byline-field-blocks-add-row — add-button row at the bottom
|
|
9
10
|
* .byline-field-blocks-add-label — clickable text label in the add row
|
|
10
11
|
* .byline-field-blocks-modal-head — picker modal header
|
|
@@ -33,6 +34,28 @@
|
|
|
33
34
|
gap: var(--spacing-16);
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
.unavailable,
|
|
38
|
+
:global(.byline-field-blocks-unavailable) {
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: flex-start;
|
|
41
|
+
gap: var(--spacing-8);
|
|
42
|
+
padding: var(--spacing-16);
|
|
43
|
+
border: 1px solid var(--stroke-warning);
|
|
44
|
+
border-radius: var(--border-radius-md);
|
|
45
|
+
background-color: oklch(from var(--fill-warning-weak) l c h / calc(alpha * 0.5));
|
|
46
|
+
color: var(--foreground);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.unavailable > :first-child,
|
|
50
|
+
:global(.byline-field-blocks-unavailable) > :first-child {
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.unavailable p,
|
|
55
|
+
:global(.byline-field-blocks-unavailable) p {
|
|
56
|
+
margin: var(--spacing-4) 0 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
36
59
|
.add-row,
|
|
37
60
|
:global(.byline-field-blocks-add-row) {
|
|
38
61
|
display: flex;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
9
|
+
import { Fragment, useEffect, useMemo, useState } from 'react'
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
BlockAdminConfig,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
Modal,
|
|
26
26
|
moveItem,
|
|
27
27
|
PlusIcon,
|
|
28
|
+
WarningIcon,
|
|
28
29
|
} from '@byline/ui/react'
|
|
29
30
|
import cx from 'clsx'
|
|
30
31
|
|
|
@@ -35,6 +36,7 @@ import { useFieldError, useFormContext } from '../../forms/form-context'
|
|
|
35
36
|
import { useScopedDomId } from '../../forms/form-dom-scope'
|
|
36
37
|
import { hasExistingIdTargets } from '../../forms/nested-path'
|
|
37
38
|
import { moveRepeatingItems, repeatingItemId, repeatingItemPath } from '../../forms/repeating-items'
|
|
39
|
+
import { useUnavailableBlocks } from '../../forms/unavailable-blocks'
|
|
38
40
|
import styles from './blocks-field.module.css'
|
|
39
41
|
|
|
40
42
|
// ---------------------------------------------------------------------------
|
|
@@ -61,6 +63,7 @@ export const BlocksField = ({
|
|
|
61
63
|
const { appendPatch, getFieldValue, getFieldValues, removePendingUploadsUnder, setFieldStore } =
|
|
62
64
|
useFormContext()
|
|
63
65
|
const { t } = useTranslation('byline-admin')
|
|
66
|
+
const unavailableBlocks = useUnavailableBlocks(path)
|
|
64
67
|
const fieldError = useFieldError(path)
|
|
65
68
|
const htmlId = useScopedDomId(path)
|
|
66
69
|
const [items, setItems] = useState<{ id: string; data: any }[]>([])
|
|
@@ -288,7 +291,28 @@ export const BlocksField = ({
|
|
|
288
291
|
onDragEnd={handleDragEnd}
|
|
289
292
|
className={cx('byline-field-blocks-stack', styles.stack)}
|
|
290
293
|
>
|
|
291
|
-
{items.
|
|
294
|
+
{Array.from({ length: items.length + 1 }, (_, index) => (
|
|
295
|
+
<Fragment key={items[index]?.id ?? 'end'}>
|
|
296
|
+
{unavailableBlocks.map((issue, removedBefore) =>
|
|
297
|
+
Math.min(issue.storedIndex - removedBefore, items.length) === index ? (
|
|
298
|
+
<div
|
|
299
|
+
key={`${issue.storedIndex}-${issue.blockType}`}
|
|
300
|
+
role="note"
|
|
301
|
+
className={cx('byline-field-blocks-unavailable', styles.unavailable)}
|
|
302
|
+
>
|
|
303
|
+
<WarningIcon width="24px" height="24px" />
|
|
304
|
+
<div>
|
|
305
|
+
<strong>
|
|
306
|
+
{t('fields.blocks.unavailableTitle', { blockType: issue.blockType })}
|
|
307
|
+
</strong>
|
|
308
|
+
<p>{t('fields.blocks.unavailableBody')}</p>
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
) : null
|
|
312
|
+
)}
|
|
313
|
+
{index < items.length && renderItem(items[index]!, index)}
|
|
314
|
+
</Fragment>
|
|
315
|
+
))}
|
|
292
316
|
<div className={cx('byline-field-blocks-add-row', styles['add-row'])}>
|
|
293
317
|
<IconButton
|
|
294
318
|
onClick={() => {
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
|
|
20
20
|
import type { ReactNode, RefObject } from 'react'
|
|
21
21
|
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
SlugifierFn,
|
|
24
|
+
StructuralMutationReceipt,
|
|
25
|
+
UnavailableBlock,
|
|
26
|
+
WorkflowStatus,
|
|
27
|
+
} from '@byline/core'
|
|
23
28
|
import { useTranslation } from '@byline/i18n/react'
|
|
24
29
|
import { Alert, Button, ComboButton, LoaderEllipsis } from '@byline/ui/react'
|
|
25
30
|
import cx from 'clsx'
|
|
@@ -263,6 +268,7 @@ export interface FormConcurrencyNoticesProps {
|
|
|
263
268
|
onDiscardRequested: () => void
|
|
264
269
|
scheduling: UseScheduledPublicationReturn
|
|
265
270
|
restoreWarnings?: string[]
|
|
271
|
+
unavailableBlocks?: UnavailableBlock[]
|
|
266
272
|
}
|
|
267
273
|
|
|
268
274
|
export const FormConcurrencyNotices = ({
|
|
@@ -276,6 +282,7 @@ export const FormConcurrencyNotices = ({
|
|
|
276
282
|
onDiscardRequested,
|
|
277
283
|
scheduling,
|
|
278
284
|
restoreWarnings,
|
|
285
|
+
unavailableBlocks,
|
|
279
286
|
}: FormConcurrencyNoticesProps): ReactNode => {
|
|
280
287
|
const { t } = useTranslation('byline-admin')
|
|
281
288
|
|
|
@@ -329,6 +336,17 @@ export const FormConcurrencyNotices = ({
|
|
|
329
336
|
onCancel={scheduling.cancel}
|
|
330
337
|
/>
|
|
331
338
|
{scheduling.modal}
|
|
339
|
+
{unavailableBlocks && unavailableBlocks.length > 0 && (
|
|
340
|
+
<Alert
|
|
341
|
+
className="m-0 mt-4"
|
|
342
|
+
intent="warning"
|
|
343
|
+
icon={true}
|
|
344
|
+
close={false}
|
|
345
|
+
title={t('forms.unavailableBlocks.title')}
|
|
346
|
+
>
|
|
347
|
+
<p>{t('forms.unavailableBlocks.body', { count: unavailableBlocks.length })}</p>
|
|
348
|
+
</Alert>
|
|
349
|
+
)}
|
|
332
350
|
{restoreWarnings && restoreWarnings.length > 0 && (
|
|
333
351
|
<Alert
|
|
334
352
|
className="m-0 mt-4"
|
|
@@ -131,7 +131,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
131
131
|
})
|
|
132
132
|
|
|
133
133
|
it('forwards adminConfig.lockPath to the path widget', () => {
|
|
134
|
-
render({ ...baseProps, adminConfig: {
|
|
134
|
+
render({ ...baseProps, adminConfig: { path: 'pages', lockPath: true } })
|
|
135
135
|
|
|
136
136
|
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
137
137
|
expect(input?.readOnly).toBe(true)
|
|
@@ -139,7 +139,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
139
139
|
})
|
|
140
140
|
|
|
141
141
|
it('leaves the path widget editable when no lockPath is declared', () => {
|
|
142
|
-
render({ ...baseProps, adminConfig: {
|
|
142
|
+
render({ ...baseProps, adminConfig: { path: 'pages' } })
|
|
143
143
|
|
|
144
144
|
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
145
145
|
expect(input?.readOnly).toBe(false)
|
|
@@ -156,7 +156,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
156
156
|
fields: [{ name: 'ref', label: 'Ref', type: 'counter' as const }],
|
|
157
157
|
useAsPath: 'ref',
|
|
158
158
|
initialData: { id: 'doc-1', path: 'about-us', fields: { ref: 448 } },
|
|
159
|
-
adminConfig: {
|
|
159
|
+
adminConfig: { path: 'pages' },
|
|
160
160
|
})
|
|
161
161
|
|
|
162
162
|
const input = container.querySelector<HTMLInputElement>('input[name="__systemPath__"]')
|
|
@@ -173,7 +173,7 @@ describe('FormRenderer capabilities', () => {
|
|
|
173
173
|
mode: 'create' as const,
|
|
174
174
|
useAsPath: undefined,
|
|
175
175
|
initialData: { fields: {} },
|
|
176
|
-
adminConfig: {
|
|
176
|
+
adminConfig: { path: 'pages', lockPath: true },
|
|
177
177
|
})
|
|
178
178
|
|
|
179
179
|
expect(container.querySelector('.byline-form-path')).not.toBeNull()
|
|
@@ -188,14 +188,14 @@ describe('FormRenderer capabilities', () => {
|
|
|
188
188
|
mode: 'create' as const,
|
|
189
189
|
useAsPath: undefined,
|
|
190
190
|
initialData: { fields: {} },
|
|
191
|
-
adminConfig: {
|
|
191
|
+
adminConfig: { path: 'pages' },
|
|
192
192
|
})
|
|
193
193
|
|
|
194
194
|
expect(container.querySelector('.byline-form-path')).toBeNull()
|
|
195
195
|
})
|
|
196
196
|
|
|
197
197
|
it('lets showPath: false override lockPath', () => {
|
|
198
|
-
render({ ...baseProps, showPath: false, adminConfig: {
|
|
198
|
+
render({ ...baseProps, showPath: false, adminConfig: { path: 'pages', lockPath: true } })
|
|
199
199
|
|
|
200
200
|
expect(container.querySelector('.byline-form-path')).toBeNull()
|
|
201
201
|
})
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
12
12
|
|
|
13
|
-
import type { AdminResourceConfig, Field, WorkflowStatus } from '@byline/core'
|
|
13
|
+
import type { AdminResourceConfig, Field, UnavailableBlock, WorkflowStatus } from '@byline/core'
|
|
14
14
|
import { getAdminConfig } from '@byline/core'
|
|
15
15
|
import { useTranslation } from '@byline/i18n/react'
|
|
16
16
|
import cx from 'clsx'
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
useScheduledPublication,
|
|
34
34
|
} from './scheduled-publication-control'
|
|
35
35
|
import { computeStatusTransitions } from './status-transitions'
|
|
36
|
+
import { UnavailableBlocksProvider } from './unavailable-blocks'
|
|
36
37
|
import { useDocumentReloadRecovery } from './use-document-reload-recovery'
|
|
37
38
|
import { type SystemFieldsSubmitPayload, useFormSubmission } from './use-form-submission'
|
|
38
39
|
import type { DocumentActionsLocaleOption } from './document-actions'
|
|
@@ -168,6 +169,7 @@ export interface FormRendererProps {
|
|
|
168
169
|
* them with the new shape.
|
|
169
170
|
*/
|
|
170
171
|
restoreWarnings?: string[]
|
|
172
|
+
unavailableBlocks?: UnavailableBlock[]
|
|
171
173
|
/**
|
|
172
174
|
* Default content locale used when no `initialLocale` is supplied and as the
|
|
173
175
|
* fallback inside `PathWidget`. Hosts typically pass their app-wide
|
|
@@ -225,6 +227,7 @@ const FormContent = ({
|
|
|
225
227
|
defaultLocale = 'en',
|
|
226
228
|
useNavigationGuard: useNavigationGuardProp,
|
|
227
229
|
restoreWarnings,
|
|
230
|
+
unavailableBlocks,
|
|
228
231
|
_activeTabBySet,
|
|
229
232
|
_onTabChange,
|
|
230
233
|
}: FormRendererProps & {
|
|
@@ -455,6 +458,7 @@ const FormContent = ({
|
|
|
455
458
|
onDiscardRequested={requestDiscard}
|
|
456
459
|
scheduling={scheduling}
|
|
457
460
|
restoreWarnings={restoreWarnings}
|
|
461
|
+
unavailableBlocks={unavailableBlocks}
|
|
458
462
|
/>
|
|
459
463
|
<FormLayout
|
|
460
464
|
fields={fields}
|
|
@@ -516,23 +520,28 @@ export const FormRenderer = (props: FormRendererProps) => {
|
|
|
516
520
|
const savedTabsRef = useRef<Record<string, string>>({})
|
|
517
521
|
|
|
518
522
|
return (
|
|
519
|
-
<
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
523
|
-
collectionPath={collectionPath ?? null}
|
|
523
|
+
<UnavailableBlocksProvider
|
|
524
|
+
issues={props.unavailableBlocks}
|
|
525
|
+
fields={initialData?.fields ?? initialData ?? {}}
|
|
524
526
|
>
|
|
525
|
-
|
|
527
|
+
<FormProvider
|
|
528
|
+
key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
|
|
529
|
+
initialData={initialData}
|
|
530
|
+
documentId={mode === 'edit' && typeof initialData?.id === 'string' ? initialData.id : null}
|
|
531
|
+
collectionPath={collectionPath ?? null}
|
|
532
|
+
>
|
|
533
|
+
{/* Forwarded wholesale. An enumerated prop list here silently dropped
|
|
526
534
|
every prop added to FormRendererProps but not copied down — which is
|
|
527
535
|
how the scheduled-publication handlers reached FormContent as
|
|
528
536
|
`undefined` and the control never rendered. */}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
+
<FormContent
|
|
538
|
+
{...props}
|
|
539
|
+
_activeTabBySet={savedTabsRef.current}
|
|
540
|
+
_onTabChange={(tabSetName, tabName) => {
|
|
541
|
+
savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName }
|
|
542
|
+
}}
|
|
543
|
+
/>
|
|
544
|
+
</FormProvider>
|
|
545
|
+
</UnavailableBlocksProvider>
|
|
537
546
|
)
|
|
538
547
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
import { act } from 'react'
|
|
10
|
+
|
|
11
|
+
import { type BlocksField as BlocksFieldType, defineAdminConfig } from '@byline/core'
|
|
12
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
13
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
import { BlocksField } from '../fields/blocks/blocks-field'
|
|
18
|
+
import { FormProvider, useFormContext } from './form-context'
|
|
19
|
+
import { FormConcurrencyNotices } from './form-page-chrome'
|
|
20
|
+
import { UnavailableBlocksProvider, useUnavailableBlocks } from './unavailable-blocks'
|
|
21
|
+
|
|
22
|
+
vi.mock('../fields/group/group-field', () => ({
|
|
23
|
+
GroupField: ({ path }: { path: string }) => {
|
|
24
|
+
const form = useFormContext()
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
data-live={path}
|
|
29
|
+
onClick={() =>
|
|
30
|
+
form.appendPatch({ kind: 'field.set', path: `${path}.text`, value: 'edited' })
|
|
31
|
+
}
|
|
32
|
+
>
|
|
33
|
+
Edit live
|
|
34
|
+
</button>
|
|
35
|
+
)
|
|
36
|
+
},
|
|
37
|
+
}))
|
|
38
|
+
vi.mock('../fields/sortable-item', () => ({
|
|
39
|
+
SortableItem: ({ children }: any) => <div>{children}</div>,
|
|
40
|
+
StaticItem: ({ children }: any) => <div>{children}</div>,
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const field: BlocksFieldType = {
|
|
44
|
+
name: 'content',
|
|
45
|
+
type: 'blocks',
|
|
46
|
+
blocks: [{ blockType: 'live', fields: [{ name: 'text', type: 'text' }] }],
|
|
47
|
+
}
|
|
48
|
+
const fields = {
|
|
49
|
+
content: [
|
|
50
|
+
{ _id: 'a', _type: 'live', text: 'A' },
|
|
51
|
+
{ _id: 'b', _type: 'live', text: 'B' },
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
const issues = [0, 2, 4].map((storedIndex) => ({
|
|
55
|
+
fieldPath: 'content',
|
|
56
|
+
storedIndex,
|
|
57
|
+
blockType: `removed-${storedIndex}`,
|
|
58
|
+
}))
|
|
59
|
+
let container: HTMLDivElement
|
|
60
|
+
let root: ReturnType<typeof createRoot>
|
|
61
|
+
let form: ReturnType<typeof useFormContext>
|
|
62
|
+
function Probe() {
|
|
63
|
+
form = useFormContext()
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
function render(reports = issues) {
|
|
67
|
+
act(() =>
|
|
68
|
+
root.render(
|
|
69
|
+
<I18nProvider
|
|
70
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
71
|
+
activeLocale="en"
|
|
72
|
+
defaultLocale="en"
|
|
73
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
74
|
+
>
|
|
75
|
+
<UnavailableBlocksProvider issues={reports} fields={fields}>
|
|
76
|
+
<FormProvider initialData={{ fields }}>
|
|
77
|
+
<Probe />
|
|
78
|
+
<FormConcurrencyNotices
|
|
79
|
+
disabled={false}
|
|
80
|
+
warningRef={{ current: null }}
|
|
81
|
+
discarding={false}
|
|
82
|
+
reloadFailed={false}
|
|
83
|
+
onDiscardRequested={() => {}}
|
|
84
|
+
scheduling={{ modal: null, state: { isExceptional: false }, timeZone: 'UTC' } as any}
|
|
85
|
+
unavailableBlocks={reports}
|
|
86
|
+
/>
|
|
87
|
+
<BlocksField field={field} path="content" defaultValue={fields.content} />
|
|
88
|
+
</FormProvider>
|
|
89
|
+
</UnavailableBlocksProvider>
|
|
90
|
+
</I18nProvider>
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
96
|
+
defineAdminConfig({
|
|
97
|
+
collections: [],
|
|
98
|
+
i18n: {
|
|
99
|
+
admin: { defaultLocale: 'en', locales: ['en'] },
|
|
100
|
+
content: { defaultLocale: 'en', locales: ['en'] },
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
container = document.createElement('div')
|
|
104
|
+
document.body.append(container)
|
|
105
|
+
root = createRoot(container)
|
|
106
|
+
})
|
|
107
|
+
afterEach(() => {
|
|
108
|
+
act(() => root.unmount())
|
|
109
|
+
container.remove()
|
|
110
|
+
vi.unstubAllGlobals()
|
|
111
|
+
})
|
|
112
|
+
it('interleaves display-only cards, keeps live patch addressing, and clears notices after reload', async () => {
|
|
113
|
+
render()
|
|
114
|
+
expect(container.textContent).toContain('This document contains 3 blocks')
|
|
115
|
+
const entries = [...container.querySelectorAll('[role="note"], [data-live]')]
|
|
116
|
+
expect(
|
|
117
|
+
entries.map(
|
|
118
|
+
(entry) => entry.getAttribute('data-live') ?? entry.querySelector('strong')?.textContent
|
|
119
|
+
)
|
|
120
|
+
).toEqual([
|
|
121
|
+
'Unavailable block: removed-0',
|
|
122
|
+
'content[id=a]',
|
|
123
|
+
'Unavailable block: removed-2',
|
|
124
|
+
'content[id=b]',
|
|
125
|
+
'Unavailable block: removed-4',
|
|
126
|
+
])
|
|
127
|
+
expect(form.getFieldValues()).toEqual(fields)
|
|
128
|
+
await act(async () => (container.querySelectorAll('[data-live]')[1] as HTMLButtonElement).click())
|
|
129
|
+
expect(form.getPatches()).toEqual([
|
|
130
|
+
{ kind: 'field.set', path: 'content[id=b].text', value: 'edited' },
|
|
131
|
+
])
|
|
132
|
+
render([])
|
|
133
|
+
expect(container.querySelector('[role="note"]')).toBeNull()
|
|
134
|
+
expect(container.textContent).not.toContain('This document contains')
|
|
135
|
+
})
|
|
136
|
+
it('binds nested paths to surviving parent identities', () => {
|
|
137
|
+
function NestedProbe() {
|
|
138
|
+
const issues = useUnavailableBlocks('outer[id=parent].body[id=second].content')
|
|
139
|
+
return <span>{issues.map((issue) => issue.blockType).join(',')}</span>
|
|
140
|
+
}
|
|
141
|
+
act(() =>
|
|
142
|
+
root.render(
|
|
143
|
+
<UnavailableBlocksProvider
|
|
144
|
+
issues={[{ fieldPath: 'outer[0].body[1].content', storedIndex: 0, blockType: 'removed' }]}
|
|
145
|
+
fields={{ outer: [{ _id: 'parent', body: [{ _id: 'first' }, { _id: 'second' }] }] }}
|
|
146
|
+
>
|
|
147
|
+
<NestedProbe />
|
|
148
|
+
</UnavailableBlocksProvider>
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
expect(container.textContent).toBe('removed')
|
|
152
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
import type { ReactNode } from 'react'
|
|
10
|
+
import { createContext, useContext, useMemo } from 'react'
|
|
11
|
+
|
|
12
|
+
import { parseInstancePath, type UnavailableBlock } from '@byline/core'
|
|
13
|
+
|
|
14
|
+
import { repeatingItemPath } from './repeating-items'
|
|
15
|
+
|
|
16
|
+
const UnavailableBlocksContext = createContext<readonly UnavailableBlock[]>([])
|
|
17
|
+
|
|
18
|
+
/** Bind numeric read paths to initial item identities, so cards follow moved parents. */
|
|
19
|
+
function bindUnavailableBlocks(
|
|
20
|
+
issues: readonly UnavailableBlock[],
|
|
21
|
+
fields: Record<string, any>
|
|
22
|
+
): UnavailableBlock[] {
|
|
23
|
+
return issues.map((issue) => {
|
|
24
|
+
const parsed = parseInstancePath(issue.fieldPath)
|
|
25
|
+
if (!parsed.ok) return issue
|
|
26
|
+
let value: any = fields
|
|
27
|
+
let path = ''
|
|
28
|
+
for (const segment of parsed.segments) {
|
|
29
|
+
if (segment.kind === 'field') {
|
|
30
|
+
path = path ? `${path}.${segment.name}` : segment.name
|
|
31
|
+
value = value?.[segment.name]
|
|
32
|
+
} else if (segment.kind === 'index') {
|
|
33
|
+
value = value?.[segment.index]
|
|
34
|
+
path = repeatingItemPath(path, value, segment.index)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ...issue, fieldPath: path }
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function UnavailableBlocksProvider({
|
|
42
|
+
issues,
|
|
43
|
+
fields,
|
|
44
|
+
children,
|
|
45
|
+
}: {
|
|
46
|
+
issues?: readonly UnavailableBlock[]
|
|
47
|
+
fields: Record<string, any>
|
|
48
|
+
children: ReactNode
|
|
49
|
+
}) {
|
|
50
|
+
const bound = useMemo(() => bindUnavailableBlocks(issues ?? [], fields), [issues, fields])
|
|
51
|
+
return (
|
|
52
|
+
<UnavailableBlocksContext.Provider value={bound}>{children}</UnavailableBlocksContext.Provider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useUnavailableBlocks(path: string) {
|
|
57
|
+
return useContext(UnavailableBlocksContext).filter((issue) => issue.fieldPath === path)
|
|
58
|
+
}
|