@cosmicdrift/kumiko-renderer 0.394.0 → 0.396.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,6 +1,5 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useId } from "react";
3
- import { escapeHtmlAttr } from "../headless/index.js";
4
3
  import { useContentEditor } from "./content-editors.js";
5
4
  const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}/g;
6
5
  const noop = () => { };
@@ -12,15 +11,8 @@ export function substituteVariables(content, variables) {
12
11
  export function ContentPreview({ content, variables, contentFormat, }) {
13
12
  const Editor = useContentEditor(contentFormat);
14
13
  const id = useId();
15
- // "rich" content is HTML (see ContentCollectionDefinition.contentFormat) —
16
- // an example value substituted in raw could break the markup (`<`) or
17
- // render as an unescaped entity (`&`). "plain"/"markdown" content is text,
18
- // no escaping wanted there.
19
- const safeVariables = contentFormat === "rich"
20
- ? Object.fromEntries(Object.entries(variables).map(([name, value]) => [name, escapeHtmlAttr(value)]))
21
- : variables;
22
14
  // "document" keeps `{{x}}` as-is: substituting it into a button/link href would make the
23
15
  // schema drop that node; variable chips stay visible.
24
- const value = contentFormat === "document" ? content : substituteVariables(content, safeVariables);
16
+ const value = contentFormat === "document" ? content : substituteVariables(content, variables);
25
17
  return _jsx(Editor, { id: id, value: value, onChange: noop, variables: [], readOnly: true });
26
18
  }
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useMemo, useState } from "react";
3
+ import { FormScreenLayout } from "../components/form-screen-layout.js";
3
4
  import { useDispatcher } from "../context/dispatcher-context.js";
4
5
  import { useUserRoles } from "../context/user-roles-context.js";
5
6
  import { useQuery } from "../hooks/use-query.js";
@@ -20,7 +21,7 @@ function groupSectionsByTitle(sections) {
20
21
  // unlike ConfigEditBody there is no server value to pre-fill a draft with —
21
22
  // every input starts at "" and stays that way unless the user types into it.
22
23
  export function SecretsEditBody({ screen, translate }) {
23
- const { Banner, Dialog, Form, Section, Field, Input, Button, Text, Grid } = usePrimitives();
24
+ const { Banner, Dialog, Section, Field, Input, Button, Text, Grid } = usePrimitives();
24
25
  const t = useTranslation();
25
26
  const effectiveTranslate = translate ?? t;
26
27
  const dispatcher = useDispatcher();
@@ -111,10 +112,10 @@ export function SecretsEditBody({ screen, translate }) {
111
112
  }))
112
113
  .filter((group) => group.fields.length > 0);
113
114
  const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
114
- return (_jsxs(_Fragment, { children: [_jsxs(Form, { onSubmit: () => {
115
+ return (_jsxs(_Fragment, { children: [_jsxs(FormScreenLayout, { onSubmit: () => {
115
116
  void handleSubmit();
116
- }, testId: "secrets-edit-form", width: "full", fillHeight: true, stickyActions: true, screenForm: true, unsavedCount: unsavedCount, ...(screen.description !== undefined && {
117
- subtitle: effectiveTranslate(screen.description),
117
+ }, testId: "secrets-edit-form", width: "full", unsavedCount: unsavedCount, ...(screen.description !== undefined && {
118
+ description: effectiveTranslate(screen.description),
118
119
  }), actions: _jsxs(_Fragment, { children: [unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: () => setDrafts({}), testId: "secrets-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: effectiveTranslate("kumiko.form.discard") })), _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting || unsavedCount === 0, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.form.saveChanges") })] }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), sectionGroups.map((section) => (_jsx(Section, { layout: "settings-list", title: effectiveTranslate(section.title), subtitle: effectiveTranslate("config.secrets.description"), children: _jsx(Grid, { columns: 1, list: true, children: section.fields.map((fieldId) => {
119
120
  const qualified = screen.secretKeys[fieldId];
120
121
  if (qualified === undefined)
@@ -7,7 +7,8 @@ export type FormScreenLayoutProps = {
7
7
  readonly secondaryActions?: ReactNode;
8
8
  readonly onSubmit?: () => void;
9
9
  readonly width?: FormWidth;
10
+ readonly unsavedCount?: number;
10
11
  readonly testId?: string;
11
12
  };
12
13
  /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
13
- export declare function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit, width, testId, }: FormScreenLayoutProps): ReactNode;
14
+ export declare function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit, width, unsavedCount, testId, }: FormScreenLayoutProps): ReactNode;
@@ -7,7 +7,7 @@ const FORM_SCREEN_LAYOUT_PROPS = {
7
7
  };
8
8
  const NOOP = () => { };
9
9
  /** For custom screens: the standard form screen layout; fill it with `Section` (title, icon, subtitle). */
10
- export function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit = NOOP, width, testId, }) {
10
+ export function FormScreenLayout({ children, description, actions, secondaryActions, onSubmit = NOOP, width, unsavedCount, testId, }) {
11
11
  const { Form } = usePrimitives();
12
- return (_jsx(Form, { onSubmit: onSubmit, ...FORM_SCREEN_LAYOUT_PROPS, ...(description !== undefined && { subtitle: description }), ...(actions !== undefined && { actions }), ...(secondaryActions !== undefined && { secondaryActions }), ...(width !== undefined && { width }), ...(testId !== undefined && { testId }), children: children }));
12
+ return (_jsx(Form, { onSubmit: onSubmit, ...FORM_SCREEN_LAYOUT_PROPS, ...(description !== undefined && { subtitle: description }), ...(actions !== undefined && { actions }), ...(secondaryActions !== undefined && { secondaryActions }), ...(width !== undefined && { width }), ...(unsavedCount !== undefined && { unsavedCount }), ...(testId !== undefined && { testId }), children: children }));
13
13
  }
@@ -52,7 +52,7 @@ export function renderFormErrorBanner({ engine: { props, state }, layout, }) {
52
52
  onReload();
53
53
  state.setFormError(null);
54
54
  state.setExtraDeleteIssues([]);
55
- }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), layout.rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), state.extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })));
55
+ }, testId: "render-edit-form-error-reload", children: translate("kumiko.actions.reload") })) : undefined, children: [_jsx(Text, { testId: "render-edit-form-error-key", children: translate(formError.i18nKey, formError.i18nParams) }), layout.rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), layout.unplacedIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-unplaced", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), state.extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })));
56
56
  }
57
57
  export function renderRootIssueBanners({ engine: { state }, layout, }) {
58
58
  const { Banner, Text } = state.primitives;
@@ -3,6 +3,7 @@ import type { FieldIssue, FormValues } from "../headless/index.ts";
3
3
  import type { RenderEditEngine } from "./render-edit-render-model.ts";
4
4
  export type LayoutFlags = {
5
5
  readonly rootIssues: readonly FieldIssue[];
6
+ readonly unplacedIssues: readonly FieldIssue[];
6
7
  readonly isScreenForm: boolean;
7
8
  readonly titleInShell: boolean;
8
9
  readonly menuPageHeaderActions: boolean;
@@ -25,5 +26,5 @@ export type FooterFlags = {
25
26
  readonly hasUnsavedInput: boolean;
26
27
  };
27
28
  export type FooterSlotComponent = ReturnType<typeof useExtensionSectionComponent>;
28
- export declare function deriveLayoutFlags<TValues extends FormValues, TCtx>({ props, state, form, }: RenderEditEngine<TValues, TCtx>): LayoutFlags;
29
+ export declare function deriveLayoutFlags<TValues extends FormValues, TCtx>({ props, state, form, sections, }: RenderEditEngine<TValues, TCtx>): LayoutFlags;
29
30
  export declare function deriveFooterFlags<TValues extends FormValues, TCtx>(engine: RenderEditEngine<TValues, TCtx>, layout: LayoutFlags, footerSlotComponent: FooterSlotComponent): FooterFlags;
@@ -1,6 +1,6 @@
1
1
  import { PROJECTION_DETAIL_ENTITY } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import { shouldRenderActionsIconOnly } from "../primitives.js";
3
- import { ROOT_ISSUE_PATH, resolveExtensionEntityId } from "./render-edit-logic.js";
3
+ import { issuesWithoutRenderedField, ROOT_ISSUE_PATH, resolveExtensionEntityId, } from "./render-edit-logic.js";
4
4
  function hasSecondaryFormActions(props, menuPageHeaderActions, cancelInHeaderMenu) {
5
5
  const { onDelete, onCopyLink, actions, menuActions, onCancel } = props;
6
6
  const hasRecordActions = !menuPageHeaderActions && (onDelete !== undefined || onCopyLink !== undefined);
@@ -9,7 +9,7 @@ function hasSecondaryFormActions(props, menuPageHeaderActions, cancelInHeaderMen
9
9
  const hasCancel = onCancel !== undefined && !cancelInHeaderMenu;
10
10
  return hasRecordActions || hasCustomActions || hasMenuActions || hasCancel;
11
11
  }
12
- export function deriveLayoutFlags({ props, state, form, }) {
12
+ export function deriveLayoutFlags({ props, state, form, sections, }) {
13
13
  const { fillScreenHeight, hideSectionTitles, headerRegion, actions, onCancel } = props;
14
14
  const { PageHeader, ActionOverflowMenu } = state.primitives;
15
15
  // Screen forms (entityEdit/actionForm filling the shell height) drop the card;
@@ -24,6 +24,7 @@ export function deriveLayoutFlags({ props, state, form, }) {
24
24
  const cancelInHeaderMenu = menuPageHeaderActions && state.isWizard && onCancel !== undefined;
25
25
  return {
26
26
  rootIssues,
27
+ unplacedIssues: issuesWithoutRenderedField(form.snapshot.errors, sections.filteredSections, new Set(Object.keys(props.formDerivation?.derive ?? {}))),
27
28
  isScreenForm,
28
29
  titleInShell,
29
30
  menuPageHeaderActions,
@@ -1,9 +1,10 @@
1
- import type { DispatcherError, EditFieldViewModel, EditSectionViewModel, SubmitResult } from "../headless/index.ts";
1
+ import type { DispatcherError, EditFieldViewModel, EditSectionViewModel, FieldIssue, SubmitResult } from "../headless/index.ts";
2
2
  export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
3
3
  export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
4
4
  export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
5
5
  export declare function issueRootField(path: string): string;
6
6
  export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[], derivedTargets?: ReadonlySet<string>): boolean;
7
+ export declare function issuesWithoutRenderedField(errors: Readonly<Record<string, readonly FieldIssue[]>>, sections: readonly EditSectionViewModel[], derivedTargets?: ReadonlySet<string>): readonly FieldIssue[];
7
8
  export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
8
9
  export declare function findFirstErroringSectionIndex(sections: readonly EditSectionViewModel[], issuePaths: readonly string[]): number | undefined;
9
10
  export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
@@ -42,6 +42,20 @@ export function hasIssueWithoutRenderedField(issuePaths, sections, derivedTarget
42
42
  const renderedFields = sections.flatMap((s) => s.kind === "fields" && s.visible ? s.fields : []);
43
43
  return hasIssueWithoutVisibleField(paths, renderedFields);
44
44
  }
45
+ // The server issues the banner has to carry itself because no rendered field shows them
46
+ // (an extension section's value such as a page body, `version`, a hidden field).
47
+ export function issuesWithoutRenderedField(errors, sections, derivedTargets = new Set()) {
48
+ const visibleFields = new Set(sections
49
+ .flatMap((s) => (s.kind === "fields" && s.visible ? s.fields : []))
50
+ .filter((f) => f.visible)
51
+ .map((f) => f.field));
52
+ return Object.entries(errors)
53
+ .filter(([path]) => {
54
+ const root = issueRootField(path);
55
+ return path !== ROOT_ISSUE_PATH && !derivedTargets.has(root) && !visibleFields.has(root);
56
+ })
57
+ .flatMap(([, issues]) => issues);
58
+ }
45
59
  export function hasIssueWithoutVisibleField(issuePaths, fields) {
46
60
  if (issuePaths.length === 0)
47
61
  return true;
@@ -72,7 +72,7 @@ export type ClientFeatureDefinition = {
72
72
  * `navProviders`; the static map wins on key collision (explicit beats
73
73
  * derived). */
74
74
  readonly navProvidersFromCollections?: (collections: readonly QualifiedContentCollection[]) => CollectionNavProviders;
75
- /** Content editor components — map `contentFormat` ("plain" | "rich" | "markdown" | "document") →
75
+ /** Content editor components — map `contentFormat` ("plain" | "document") →
76
76
  * React component. `r.contentCollection()` declares which format an
77
77
  * editor edits; the collection's editor UI (template-resolver's
78
78
  * TextBlockEditor et al.) looks the component up here and falls back to
@@ -12,7 +12,6 @@ export type { KumikoLinkProps } from "./app/nav.tsx";
12
12
  export { KumikoLink, useBrowserNavApi } from "./app/nav.tsx";
13
13
  export { PlainContentEditor } from "./app/plain-content-editor.tsx";
14
14
  export { useResolvers } from "./app/resolvers-context.tsx";
15
- export { RichContentEditor } from "./app/rich-content-editor.tsx";
16
15
  export { clearRememberedListState } from "./app/session-list-state-store.ts";
17
16
  export type { NavIconKey } from "./icons.tsx";
18
17
  export { Icon } from "./icons.tsx";
@@ -43,6 +42,7 @@ export { SidebarBrand } from "./layout/sidebar-brand.tsx";
43
42
  export { SidebarPanel } from "./layout/sidebar-panel.tsx";
44
43
  export type { SidebarUserProps } from "./layout/sidebar-user.tsx";
45
44
  export { SidebarUser } from "./layout/sidebar-user.tsx";
45
+ export { useDispatchTarget } from "./layout/target-resolver-stub.tsx";
46
46
  export { parseTargetFromSearchParams } from "./layout/target-url.ts";
47
47
  export type { ThemeMenuItemProps } from "./layout/theme-menu-item.tsx";
48
48
  export { ThemeMenuItem } from "./layout/theme-menu-item.tsx";
package/dist/web/index.js CHANGED
@@ -11,7 +11,6 @@ export { createBrowserDraftStorage } from "./app/draft-storage.js";
11
11
  export { KumikoLink, useBrowserNavApi } from "./app/nav.js";
12
12
  export { PlainContentEditor } from "./app/plain-content-editor.js";
13
13
  export { useResolvers } from "./app/resolvers-context.js";
14
- export { RichContentEditor } from "./app/rich-content-editor.js";
15
14
  export { clearRememberedListState } from "./app/session-list-state-store.js";
16
15
  export { Icon } from "./icons.js";
17
16
  export { AppLayout } from "./layout/app-layout.js";
@@ -28,6 +27,7 @@ export { Sidebar } from "./layout/sidebar.js";
28
27
  export { SidebarBrand } from "./layout/sidebar-brand.js";
29
28
  export { SidebarPanel } from "./layout/sidebar-panel.js";
30
29
  export { SidebarUser } from "./layout/sidebar-user.js";
30
+ export { useDispatchTarget } from "./layout/target-resolver-stub.js";
31
31
  export { parseTargetFromSearchParams } from "./layout/target-url.js";
32
32
  export { ThemeMenuItem } from "./layout/theme-menu-item.js";
33
33
  export { ThemeToggle } from "./layout/theme-toggle.js";
@@ -3,7 +3,10 @@ export type FileRefTarget = Pick<FileUploadFormDataInput, "entityType" | "entity
3
3
  /** Carries the server's own `error` message when the upload endpoint sent one. */
4
4
  export declare class FileUploadFailedError extends Error {
5
5
  readonly fromServer: boolean;
6
- constructor(message: string, fromServer: boolean);
6
+ readonly httpStatus?: number | undefined;
7
+ readonly code?: string | undefined;
8
+ constructor(message: string, fromServer: boolean, httpStatus?: number | undefined, code?: string | undefined);
7
9
  }
10
+ export declare function isSessionEndedUploadError(cause: unknown): boolean;
8
11
  /** Posts one file to the stack's file endpoint (double-submit CSRF) and returns the new FileRef id. */
9
12
  export declare function uploadFileRef(file: Blob, target?: FileRefTarget): Promise<string>;
@@ -1,14 +1,25 @@
1
1
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
2
  import { CSRF_HEADER_NAME, readCsrfToken } from "../../live/index.js";
3
+ import { isSessionEndedError } from "../../session/session-ended.js";
3
4
  import { buildFileUploadFormData } from "./file-upload-form-data.js";
4
5
  /** Carries the server's own `error` message when the upload endpoint sent one. */
5
6
  export class FileUploadFailedError extends Error {
6
7
  fromServer;
7
- constructor(message, fromServer) {
8
+ httpStatus;
9
+ code;
10
+ constructor(message, fromServer, httpStatus, code) {
8
11
  super(message);
9
12
  this.fromServer = fromServer;
13
+ this.httpStatus = httpStatus;
14
+ this.code = code;
10
15
  }
11
16
  }
17
+ export function isSessionEndedUploadError(cause) {
18
+ return (cause instanceof FileUploadFailedError &&
19
+ cause.code !== undefined &&
20
+ cause.httpStatus !== undefined &&
21
+ isSessionEndedError({ code: cause.code, httpStatus: cause.httpStatus }));
22
+ }
12
23
  /** Posts one file to the stack's file endpoint (double-submit CSRF) and returns the new FileRef id. */
13
24
  export async function uploadFileRef(file, target) {
14
25
  const csrf = readCsrfToken();
@@ -21,9 +32,10 @@ export async function uploadFileRef(file, target) {
21
32
  const id = propertyOf(json, "id");
22
33
  if (!res.ok || typeof id !== "string") {
23
34
  const message = propertyOf(json, "error");
35
+ const code = propertyOf(json, "code");
24
36
  throw typeof message === "string"
25
- ? new FileUploadFailedError(message, true)
26
- : new FileUploadFailedError(`upload failed (${res.status})`, false);
37
+ ? new FileUploadFailedError(message, true, res.status, typeof code === "string" ? code : undefined)
38
+ : new FileUploadFailedError(`upload failed (${res.status})`, false, res.status);
27
39
  }
28
40
  return id;
29
41
  }
@@ -6,17 +6,18 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
6
6
  // file name link. A disabled input shows the value only, without controls.
7
7
  import { ImageIcon, Loader2, Upload } from "lucide-react";
8
8
  import { useRef, useState } from "react";
9
- import { useTranslation } from "../../index.js";
9
+ import { useSessionEndedSignal, useTranslation } from "../../index.js";
10
10
  import { toAcceptAttr } from "../lib/accept-attr.js";
11
11
  import { resizeImageBeforeUpload } from "../lib/resize-image.js";
12
12
  import { uploadErrorKey } from "../lib/upload-error-key.js";
13
- import { uploadFileRef } from "../lib/upload-file-ref.js";
13
+ import { isSessionEndedUploadError, uploadFileRef } from "../lib/upload-file-ref.js";
14
14
  import { Button as UiButton } from "../ui/button.js";
15
15
  import { FileNameLink, FileThumbnail } from "./file-thumbnail.js";
16
16
  // React's input typings have no `webkitdirectory`; spreading keeps it typed-cast-free.
17
17
  const DIRECTORY_INPUT_ATTRS = { webkitdirectory: "", multiple: true };
18
18
  export function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, uploadActionForm, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }) {
19
19
  const t = useTranslation();
20
+ const sessionEnded = useSessionEndedSignal();
20
21
  const inputRef = useRef(null);
21
22
  const [uploading, setUploading] = useState(false);
22
23
  const [error, setError] = useState(null);
@@ -42,6 +43,8 @@ export function FileUploadInput({ kind, id, value, onChange, accept, disabled, e
42
43
  }));
43
44
  }
44
45
  catch (cause) {
46
+ if (isSessionEndedUploadError(cause))
47
+ sessionEnded?.notify();
45
48
  setError(t(uploadErrorKey(cause)));
46
49
  }
47
50
  finally {
@@ -3,12 +3,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  // file, the field value is the array of FileRef ids of the files that uploaded. Failed files stay
4
4
  // out of the value; removing a row removes its id.
5
5
  import { useEffect, useRef } from "react";
6
- import { useTranslation } from "../../index.js";
6
+ import { useSessionEndedSignal, useTranslation } from "../../index.js";
7
7
  import { FileCountLimitError, uploadErrorKey } from "../lib/upload-error-key.js";
8
- import { uploadFileRef } from "../lib/upload-file-ref.js";
8
+ import { isSessionEndedUploadError, uploadFileRef, } from "../lib/upload-file-ref.js";
9
9
  import { UploadZone } from "../widgets/upload-zone.js";
10
10
  export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCount, disabled, entityType, uploadActionForm, entityId, fieldName, testId, }) {
11
11
  const t = useTranslation();
12
+ const sessionEnded = useSessionEndedSignal();
12
13
  // Uploads of one batch finish independently; building each next value from the render's
13
14
  // `value` would drop the ids of the others. Re-synced whenever the owner changes the value.
14
15
  const latestIds = useRef(value);
@@ -33,6 +34,11 @@ export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCo
33
34
  onChange(latestIds.current);
34
35
  return fileId;
35
36
  }
37
+ catch (cause) {
38
+ if (isSessionEndedUploadError(cause))
39
+ sessionEnded?.notify();
40
+ throw cause;
41
+ }
36
42
  finally {
37
43
  uploadsRunning.current -= 1;
38
44
  }
@@ -84,6 +84,12 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
84
84
  requestSeq.current += 1;
85
85
  };
86
86
  }, [load]);
87
+ // Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
88
+ // into one query per event. A refresh already running schedules
89
+ // exactly one trailing pass after it settles.
90
+ const liveTimerRef = useRef(null);
91
+ const liveInFlightRef = useRef(false);
92
+ const liveTrailingRef = useRef(false);
87
93
  // Live-mode: on an SSE event for the query's entity, refetch only the
88
94
  // first page and merge it in — rows the fresh page still contains move
89
95
  // to the front (newest-first feeds), rows it dropped (edited/deleted
@@ -98,8 +104,12 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
98
104
  // rows bumps (below), because those are newer than the running load's.
99
105
  const seqAtStart = requestSeq.current;
100
106
  const res = await dispatcher.query(query, { ...payload, limit: pageSize });
101
- if (seqAtStart !== requestSeq.current)
107
+ if (seqAtStart !== requestSeq.current) {
108
+ // A load-more bumps the sequence without covering page 1, so the signal that
109
+ // triggered this refresh would otherwise be lost.
110
+ liveTrailingRef.current = true;
102
111
  return;
112
+ }
103
113
  // skip: background live refresh failed, keep showing the current rows
104
114
  if (!res.isSuccess)
105
115
  return;
@@ -137,12 +147,6 @@ export function InfinityList({ query, payload, pageSize = 50, rows, nextCursor,
137
147
  return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
138
148
  });
139
149
  }, [dispatcher, query, pageSize, payloadKey]);
140
- // Trailing debounce + in-flight coalesce: bulk SSE storms must not fan out
141
- // into one query per event. A refresh already running schedules
142
- // exactly one trailing pass after it settles.
143
- const liveTimerRef = useRef(null);
144
- const liveInFlightRef = useRef(false);
145
- const liveTrailingRef = useRef(false);
146
150
  useEffect(() => {
147
151
  // skip: live mode off, no SSE subscription needed
148
152
  if (!live)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.394.0",
3
+ "version": "0.396.0",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,12 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.394.0",
52
- "@cosmicdrift/kumiko-types": "0.394.0",
53
- "@tiptap/core": "^3.31.3",
54
- "@tiptap/pm": "^3.31.3",
55
- "@tiptap/react": "^3.31.3",
56
- "@tiptap/starter-kit": "^3.31.3",
51
+ "@cosmicdrift/kumiko-framework": "0.396.0",
52
+ "@cosmicdrift/kumiko-types": "0.396.0",
57
53
  "@types/qrcode": "^1.5.5",
58
54
  "class-variance-authority": "^0.7.1",
59
55
  "clsx": "^2.1.1",
@@ -83,8 +79,8 @@
83
79
  }
84
80
  },
85
81
  "devDependencies": {
86
- "@cosmicdrift/kumiko-locale-de": "0.394.0",
87
- "@cosmicdrift/kumiko-testing": "0.394.0",
82
+ "@cosmicdrift/kumiko-locale-de": "0.396.0",
83
+ "@cosmicdrift/kumiko-testing": "0.396.0",
88
84
  "@tailwindcss/cli": "^4.3.0",
89
85
  "@testing-library/react": "^16.3.2",
90
86
  "@testing-library/user-event": "^14.6.1",
package/src/changes.json CHANGED
@@ -1,4 +1,16 @@
1
1
  [
2
+ {
3
+ "version": "0.395.0",
4
+ "type": "improvement",
5
+ "title": "Server field errors without a field on the form show in the form banner",
6
+ "detail": "An entityEdit save that fails with a field error whose path is not a rendered field (for example a field an extension section writes) now shows the message in the form error banner instead of losing it."
7
+ },
8
+ {
9
+ "version": "0.395.0",
10
+ "type": "fix",
11
+ "title": "A live refresh that a load-more supersedes is retried instead of lost",
12
+ "detail": "When a live signal triggered a first-page refresh while the list loaded its next page, the refresh result was discarded and nothing re-ran it, so the change on page 1 stayed invisible until the next event. The discarded refresh now runs once more."
13
+ },
2
14
  {
3
15
  "version": "0.394.0",
4
16
  "type": "improvement",
@@ -1,3 +0,0 @@
1
- import { type ReactNode } from "react";
2
- import { type ContentEditorProps } from "../../index.ts";
3
- export declare function RichContentEditor(props: ContentEditorProps): ReactNode;
@@ -1,15 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- // @runtime client
3
- //
4
- // "rich" contentFormat editor: WYSIWYG on tiptap. Public contract is the
5
- // same four props every ContentEditorComponent gets — no tiptap type
6
- // crosses this boundary. tiptap itself is dynamic-imported (TiptapEditor
7
- // lives in ./tiptap-editor) so an app that never mounts a "rich" collection
8
- // never pays for it; the fallback while the chunk loads is the same plain
9
- // textarea uses for a missing editor, never a blank panel.
10
- import { lazy, Suspense } from "react";
11
- import { TextareaContentEditor } from "../../index.js";
12
- const TiptapEditor = lazy(() => import("./tiptap-editor.js"));
13
- export function RichContentEditor(props) {
14
- return (_jsx(Suspense, { fallback: _jsx(TextareaContentEditor, { ...props }), children: _jsx(TiptapEditor, { ...props }) }));
15
- }
@@ -1,3 +0,0 @@
1
- import { type ReactNode } from "react";
2
- import { type ContentEditorProps } from "../../index.ts";
3
- export default function TiptapEditor({ id, value, onChange, variables, readOnly, }: ContentEditorProps): ReactNode;
@@ -1,120 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- // @runtime client
3
- //
4
- // tiptap-backed implementation behind RichContentEditor's lazy() boundary —
5
- // never import this file directly, tiptap must only load when a "rich"
6
- // collection is actually rendered. StarterKit covers bold/italic/lists/
7
- // headings, and its bundled Link extension autolinks on type/paste — no
8
- // link button, so no URL-prompt UI to build or test.
9
- import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
10
- import StarterKit from "@tiptap/starter-kit";
11
- import { Bold as BoldIcon, Heading1, Heading2, Italic as ItalicIcon, List, ListOrdered, } from "lucide-react";
12
- import { useEffect } from "react";
13
- import { TextareaContentEditor, usePrimitives, useTranslation, VariableChips, } from "../../index.js";
14
- import { cn } from "../lib/cn.js";
15
- function Toolbar({ actions, disabled, }) {
16
- const { Button } = usePrimitives();
17
- return (_jsx("div", { className: "flex flex-wrap gap-1 border-b border-input p-1", children: actions.map((action) => (_jsx(Button, { type: "button", variant: action.isActive ? "primary" : "secondary", size: "icon", disabled: disabled, onClick: action.onClick, ariaLabel: action.label, children: _jsx(action.icon, { size: 16 }) }, action.label))) }));
18
- }
19
- export default function TiptapEditor({ id, value, onChange, variables, readOnly, }) {
20
- const t = useTranslation();
21
- // ponytail: rich = StarterKit's schema (bold/italic/lists/headings/links).
22
- // Anything the schema doesn't model — <table>, <div>/<span>, style attrs,
23
- // <img>, classes — is dropped on first parse (setContent below, and the
24
- // initial `content: value` here). Upgrade if that ceiling is hit: add the
25
- // matching tiptap extensions, or a raw-HTML mode for markup StarterKit
26
- // can't represent.
27
- const editor = useEditor({
28
- extensions: [StarterKit],
29
- content: value,
30
- editable: !readOnly,
31
- // Suspense (RichContentEditor's lazy boundary) speculatively mounts
32
- // this component before committing it; immediate render would create
33
- // an editor instance during that throwaway pass.
34
- immediatelyRender: false,
35
- // Caller-supplied id — the Field wrapping the editor (TextBlockEditor)
36
- // associates its label via this id, see ContentEditorProps.id in
37
- // content-editors.tsx.
38
- editorProps: { attributes: { id } },
39
- onUpdate: ({ editor: e }) => onChange(e.getHTML()),
40
- });
41
- // `useEditor` alone leaves `editor.isActive(...)` reads in the render body
42
- // frozen on the last prop-driven render — @tiptap/react v3 defaults
43
- // `shouldRerenderOnTransaction` to false, so a selection-only change (e.g.
44
- // moving the cursor into bold text without typing) never triggers a
45
- // re-render on its own. `useEditorState` subscribes to transactions itself
46
- // and only re-renders when the selected slice of state actually changes.
47
- const activeState = useEditorState({
48
- editor,
49
- selector: ({ editor: e }) => e === null
50
- ? null
51
- : {
52
- bold: e.isActive("bold"),
53
- italic: e.isActive("italic"),
54
- heading1: e.isActive("heading", { level: 1 }),
55
- heading2: e.isActive("heading", { level: 2 }),
56
- bulletList: e.isActive("bulletList"),
57
- orderedList: e.isActive("orderedList"),
58
- },
59
- });
60
- // TextBlockEditor loads the entry's content asynchronously (by-slug query
61
- // resolves after mount), so `value` arrives after useEditor already read
62
- // its initial (empty) content once. Sync it in — guarded against
63
- // clobbering in-flight typing by comparing against the editor's own HTML.
64
- useEffect(() => {
65
- if (editor && value !== editor.getHTML()) {
66
- editor.commands.setContent(value, { emitUpdate: false });
67
- }
68
- }, [value, editor]);
69
- useEffect(() => {
70
- editor?.setEditable(!readOnly);
71
- }, [readOnly, editor]);
72
- if (!editor)
73
- return (_jsx(TextareaContentEditor, { id: id, value: value, onChange: onChange, variables: variables, readOnly: readOnly }));
74
- const actions = [
75
- {
76
- label: t("kumiko.contentEditor.bold"),
77
- icon: BoldIcon,
78
- isActive: activeState?.bold ?? false,
79
- onClick: () => editor.chain().focus().toggleBold().run(),
80
- },
81
- {
82
- label: t("kumiko.contentEditor.italic"),
83
- icon: ItalicIcon,
84
- isActive: activeState?.italic ?? false,
85
- onClick: () => editor.chain().focus().toggleItalic().run(),
86
- },
87
- {
88
- label: t("kumiko.contentEditor.heading1"),
89
- icon: Heading1,
90
- isActive: activeState?.heading1 ?? false,
91
- onClick: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
92
- },
93
- {
94
- label: t("kumiko.contentEditor.heading2"),
95
- icon: Heading2,
96
- isActive: activeState?.heading2 ?? false,
97
- onClick: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
98
- },
99
- {
100
- label: t("kumiko.contentEditor.bulletList"),
101
- icon: List,
102
- isActive: activeState?.bulletList ?? false,
103
- onClick: () => editor.chain().focus().toggleBulletList().run(),
104
- },
105
- {
106
- label: t("kumiko.contentEditor.orderedList"),
107
- icon: ListOrdered,
108
- isActive: activeState?.orderedList ?? false,
109
- onClick: () => editor.chain().focus().toggleOrderedList().run(),
110
- },
111
- ];
112
- const insertVariable = (name) => {
113
- editor
114
- .chain()
115
- .focus()
116
- .insertContent({ type: "text", text: `{{${name}}}` })
117
- .run();
118
- };
119
- return (_jsxs("div", { className: "rounded-md border border-input bg-transparent", children: [_jsx(Toolbar, { actions: actions, disabled: readOnly }), _jsx(EditorContent, { editor: editor, className: cn("min-h-40 px-3 py-2 text-base outline-none md:text-sm", "[&_.ProseMirror]:min-h-40 [&_.ProseMirror]:outline-none", "[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-6 [&_ol]:pl-6", "[&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-bold", "[&_a]:underline [&_a]:text-primary", "[&_strong]:font-bold [&_em]:italic") }), variables.length > 0 && (_jsx("div", { className: "border-t border-input p-1", children: _jsx(VariableChips, { variables: variables, onInsert: insertVariable, disabled: readOnly }) }))] }));
120
- }