@byline/admin 6.8.0 → 6.9.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.
@@ -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
- items.map((item, index)=>renderItem(item, index)),
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: [
@@ -2,6 +2,7 @@ import "./blocks-field_module.css";
2
2
  const blocks_field_module = {
3
3
  title: "title-lxXfG6",
4
4
  stack: "stack-Xxa89J",
5
+ unavailable: "unavailable-B9h_EU",
5
6
  "add-row": "add-row-gEqjM_",
6
7
  addRow: "add-row-gEqjM_",
7
8
  "add-label": "add-label-jiOURu",
@@ -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(FormProvider, {
237
- initialData: initialData,
238
- documentId: 'edit' === mode && 'string' == typeof initialData?.id ? initialData.id : null,
239
- collectionPath: collectionPath ?? null,
240
- children: /*#__PURE__*/ jsx(FormContent, {
241
- ...props,
242
- _activeTabBySet: savedTabsRef.current,
243
- _onTabChange: (tabSetName, tabName)=>{
244
- savedTabsRef.current = {
245
- ...savedTabsRef.current,
246
- [tabSetName]: tabName
247
- };
248
- }
249
- })
250
- }, `${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`);
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.8.0",
5
+ "version": "6.9.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.8.0",
173
- "@byline/analytics-agent": "6.8.0",
174
- "@byline/auth": "6.8.0",
175
- "@byline/core": "6.8.0",
176
- "@byline/i18n": "6.8.0",
177
- "@byline/ui": "6.8.0",
172
+ "@byline/analytics": "6.9.0",
173
+ "@byline/analytics-agent": "6.9.0",
174
+ "@byline/auth": "6.9.0",
175
+ "@byline/core": "6.9.0",
176
+ "@byline/i18n": "6.9.0",
177
+ "@byline/ui": "6.9.0",
178
178
  "@codemirror/commands": "^6.11.1",
179
179
  "@codemirror/lang-css": "^6.3.1",
180
180
  "@codemirror/lang-html": "^6.4.12",
@@ -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.map((item, index) => renderItem(item, index))}
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 { SlugifierFn, StructuralMutationReceipt, WorkflowStatus } from '@byline/core'
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: { slug: 'pages', lockPath: true } })
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: { slug: 'pages' } })
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: { slug: 'pages' },
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: { slug: 'pages', lockPath: true },
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: { slug: 'pages' },
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: { slug: 'pages', lockPath: true } })
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
- <FormProvider
520
- key={`${initialLocale ?? 'default'}-${initialData?.versionId ?? ''}`}
521
- initialData={initialData}
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
- {/* Forwarded wholesale. An enumerated prop list here silently dropped
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
- <FormContent
530
- {...props}
531
- _activeTabBySet={savedTabsRef.current}
532
- _onTabChange={(tabSetName, tabName) => {
533
- savedTabsRef.current = { ...savedTabsRef.current, [tabSetName]: tabName }
534
- }}
535
- />
536
- </FormProvider>
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
+ }