@cosmicdrift/kumiko-renderer 0.394.0 → 0.395.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/app/content-preview.js +1 -9
- package/dist/app/secrets-edit-body.js +5 -4
- package/dist/components/form-screen-layout.d.ts +2 -1
- package/dist/components/form-screen-layout.js +2 -2
- package/dist/components/render-edit-banners.js +1 -1
- package/dist/components/render-edit-layout-flags.d.ts +2 -1
- package/dist/components/render-edit-layout-flags.js +3 -2
- package/dist/components/render-edit-logic.d.ts +2 -1
- package/dist/components/render-edit-logic.js +14 -0
- package/dist/web/app/client-plugin.d.ts +1 -1
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +1 -1
- package/dist/web/lib/upload-file-ref.d.ts +4 -1
- package/dist/web/lib/upload-file-ref.js +15 -3
- package/dist/web/primitives/file-upload.js +5 -2
- package/dist/web/primitives/files-dropzone.js +8 -2
- package/dist/web/widgets/infinity-list.js +11 -7
- package/package.json +5 -9
- package/src/changes.json +12 -0
- package/dist/web/app/rich-content-editor.d.ts +0 -3
- package/dist/web/app/rich-content-editor.js +0 -15
- package/dist/web/app/tiptap-editor.d.ts +0 -3
- package/dist/web/app/tiptap-editor.js +0 -120
|
@@ -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,
|
|
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,
|
|
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(
|
|
115
|
+
return (_jsxs(_Fragment, { children: [_jsxs(FormScreenLayout, { onSubmit: () => {
|
|
115
116
|
void handleSubmit();
|
|
116
|
-
}, testId: "secrets-edit-form", width: "full",
|
|
117
|
-
|
|
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" | "
|
|
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
|
package/dist/web/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.395.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.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "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.395.0",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.395.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.
|
|
87
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
82
|
+
"@cosmicdrift/kumiko-locale-de": "0.395.0",
|
|
83
|
+
"@cosmicdrift/kumiko-testing": "0.395.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,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,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
|
-
}
|