@cosmicdrift/kumiko-renderer 0.382.0 → 0.383.1
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/action-form-shim.js +4 -3
- package/dist/app/entity-list-expanded-row.js +2 -1
- package/dist/app/error-reference.d.ts +11 -0
- package/dist/app/error-reference.js +18 -0
- package/dist/app/header-actions-bar.d.ts +2 -1
- package/dist/app/kumiko-screen.js +9 -16
- package/dist/app/projection-detail-shim.js +2 -3
- package/dist/app/query-state-banners.js +3 -2
- package/dist/app/return-to.js +14 -8
- package/dist/app/secrets-edit-body.js +0 -1
- package/dist/components/error-boundary.d.ts +9 -2
- package/dist/components/error-boundary.js +9 -7
- package/dist/components/error-id.d.ts +1 -0
- package/dist/components/error-id.js +16 -0
- package/dist/components/list-empty-state.js +2 -1
- package/dist/components/related-list-section.js +2 -1
- package/dist/components/render-edit-action-button.d.ts +2 -1
- package/dist/components/render-edit-action-button.js +2 -1
- package/dist/components/render-edit.js +2 -1
- package/dist/components/use-action-menu-items.d.ts +2 -1
- package/dist/components/use-action-menu-items.js +2 -1
- package/dist/components/write-form-section.js +6 -2
- package/dist/headless/view-model/edit.js +1 -1
- package/dist/headless/view-model/types.d.ts +1 -1
- package/dist/i18n-defaults.js +4 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/leave-guard.js +14 -3
- package/dist/web/app/client-error-reporter.d.ts +15 -0
- package/dist/web/app/client-error-reporter.js +83 -0
- package/dist/web/app/create-app.d.ts +9 -1
- package/dist/web/app/create-app.js +16 -2
- package/dist/web/app/dashboard-body.js +2 -1
- package/dist/web/app/root-error-fallback.d.ts +4 -2
- package/dist/web/app/root-error-fallback.js +79 -6
- package/dist/web/app/screen-error-fallback.d.ts +2 -1
- package/dist/web/app/screen-error-fallback.js +6 -5
- package/dist/web/layout/editor-panel.js +2 -2
- package/dist/web/layout/target-resolver-stub.js +5 -3
- package/dist/web/primitives/index.js +4 -1
- package/dist/web/primitives/toast.d.ts +1 -0
- package/dist/web/primitives/toast.js +7 -1
- package/package.json +5 -5
- package/src/changes.json +31 -0
|
@@ -37,9 +37,10 @@ function lockExcludedFieldsInSection(section, excluded) {
|
|
|
37
37
|
return section;
|
|
38
38
|
return {
|
|
39
39
|
...section,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
...("fields" in section && {
|
|
41
|
+
fields: section.fields.map((spec) => lockExcludedFieldSpec(spec, excluded)),
|
|
42
|
+
}),
|
|
43
|
+
...("groups" in section && {
|
|
43
44
|
groups: section.groups.map((group) => ({
|
|
44
45
|
...group,
|
|
45
46
|
fields: group.fields.map((spec) => lockExcludedFieldSpec(spec, excluded)),
|
|
@@ -6,6 +6,7 @@ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
|
6
6
|
import { useTranslation } from "../i18n.js";
|
|
7
7
|
import { usePrimitives } from "../primitives.js";
|
|
8
8
|
import { useClientActionRuntime } from "./client-actions.js";
|
|
9
|
+
import { ErrorReference } from "./error-reference.js";
|
|
9
10
|
import { useNav } from "./nav.js";
|
|
10
11
|
import { useReturnHost } from "./return-to.js";
|
|
11
12
|
import { buildRecordActions } from "./row-actions.js";
|
|
@@ -49,5 +50,5 @@ export function EntityListExpandedRow({ section, row, featureName, translate, ex
|
|
|
49
50
|
clientActions,
|
|
50
51
|
]);
|
|
51
52
|
const titleActionButtons = titleActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id)));
|
|
52
|
-
return (_jsxs(_Fragment, { children: [actionError !== null && (
|
|
53
|
+
return (_jsxs(_Fragment, { children: [actionError !== null && (_jsxs(Banner, { variant: "error", testId: `row-${row.id}-expansion-action-error`, children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), _jsx(RelatedListSection, { embedded: true, section: section, parentId: String(row.values["id"] ?? row.id), record: row.values, featureName: featureName, translate: effectiveTranslate, onOpenDrawer: openDrawer, onAfterWrite: onAfterWrite, ...(titleActionButtons !== undefined && { actions: titleActionButtons }) }, expansionNonce)] }));
|
|
53
54
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
/** An error message plus the server request id support needs to find the log line. */
|
|
3
|
+
export type ErrorNotice = {
|
|
4
|
+
readonly message: string;
|
|
5
|
+
readonly requestId?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function errorNoticeOf(error: unknown): ErrorNotice;
|
|
8
|
+
export declare function ErrorReference({ requestId, testId, }: {
|
|
9
|
+
readonly requestId: string | undefined;
|
|
10
|
+
readonly testId?: string;
|
|
11
|
+
}): ReactNode;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useTranslation } from "../i18n.js";
|
|
3
|
+
import { usePrimitives } from "../primitives.js";
|
|
4
|
+
import { WriteFailedError } from "./write-failed-error.js";
|
|
5
|
+
export function errorNoticeOf(error) {
|
|
6
|
+
if (error instanceof WriteFailedError) {
|
|
7
|
+
const { requestId } = error.dispatcherError;
|
|
8
|
+
return { message: error.message, ...(requestId !== undefined && { requestId }) };
|
|
9
|
+
}
|
|
10
|
+
return { message: error instanceof Error ? error.message : String(error) };
|
|
11
|
+
}
|
|
12
|
+
export function ErrorReference({ requestId, testId = "error-reference", }) {
|
|
13
|
+
const { Grid, Text, CopyButton } = usePrimitives();
|
|
14
|
+
const t = useTranslation();
|
|
15
|
+
if (requestId === undefined || requestId === "")
|
|
16
|
+
return null;
|
|
17
|
+
return (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { variant: "small", testId: `${testId}-id`, children: t("kumiko.error.reference", { id: requestId }) }), CopyButton !== undefined && (_jsx(CopyButton, { text: requestId, label: t("kumiko.error.copyReference"), copiedLabel: t("kumiko.error.referenceCopied"), className: "min-h-0 h-7", testId: `${testId}-copy` }))] }));
|
|
18
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { RenderEditAction } from "../components/render-edit-types.ts";
|
|
3
3
|
import type { usePrimitives } from "../primitives.tsx";
|
|
4
|
+
import type { ErrorNotice } from "./error-reference.tsx";
|
|
4
5
|
export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary, compact, preferredPrimaryId, PageHeader, overflowTestId, }: {
|
|
5
6
|
/** `primaryAction` of the screen: wins over the edit/primary-style precedence. */
|
|
6
7
|
readonly preferredPrimaryId?: string;
|
|
@@ -13,7 +14,7 @@ export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverfl
|
|
|
13
14
|
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
14
15
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
15
16
|
readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
|
|
16
|
-
readonly onError: (
|
|
17
|
+
readonly onError: (notice: ErrorNotice | null) => void;
|
|
17
18
|
/** Test id of the overflow menu; projectionDetail's by default. */
|
|
18
19
|
readonly overflowTestId?: string;
|
|
19
20
|
}): ReactNode;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { evalFieldCondition, isRecordLocked, isWriteFormEditSection,
|
|
2
|
+
import { evalFieldCondition, isRecordLocked, isWriteFormEditSection, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
4
4
|
import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
|
|
5
5
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
@@ -34,6 +34,7 @@ import { useCustomScreenComponent } from "./custom-screens.js";
|
|
|
34
34
|
import { useDashboardBody } from "./dashboard-body.js";
|
|
35
35
|
import { applyEditPrefill } from "./edit-prefill.js";
|
|
36
36
|
import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
|
|
37
|
+
import { ErrorReference } from "./error-reference.js";
|
|
37
38
|
import { FacetCountBridges } from "./facet-count-bridge.js";
|
|
38
39
|
import { buildFormSchema } from "./form-schema.js";
|
|
39
40
|
import { HeaderActionsBar } from "./header-actions-bar.js";
|
|
@@ -1917,14 +1918,6 @@ function ProjectionListBody({ schema, screen, translate, onRowClick, }) {
|
|
|
1917
1918
|
void rowsQuery.refetch();
|
|
1918
1919
|
} })] }));
|
|
1919
1920
|
}
|
|
1920
|
-
function metricLabelKey(metric, fieldLabels) {
|
|
1921
|
-
if (typeof metric !== "string" && metric.label !== undefined)
|
|
1922
|
-
return metric.label;
|
|
1923
|
-
return fieldLabels?.[metricField(metric)];
|
|
1924
|
-
}
|
|
1925
|
-
function metricNavigateSpec(metric) {
|
|
1926
|
-
return typeof metric === "string" ? undefined : metric.navigate;
|
|
1927
|
-
}
|
|
1928
1921
|
// Reuses runProjectionRowNavigate (row-actions.ts) instead of re-deriving
|
|
1929
1922
|
// navigate execution — a metric click has the same entity/screen/params
|
|
1930
1923
|
// shape as a RowActionNavigate, minus the fields (id, label, style) that
|
|
@@ -1934,8 +1927,9 @@ function runMetricNavigate(nav, navigate, record, host) {
|
|
|
1934
1927
|
// activates that tab — no route change, so runProjectionRowNavigate
|
|
1935
1928
|
// (which always navigates) doesn't apply here.
|
|
1936
1929
|
if (navigate.screen === undefined && navigate.entity === undefined) {
|
|
1937
|
-
|
|
1938
|
-
|
|
1930
|
+
const { tab } = navigate;
|
|
1931
|
+
if (tab !== undefined)
|
|
1932
|
+
requestLeave(() => nav.setSearchParams({ tab }));
|
|
1939
1933
|
return;
|
|
1940
1934
|
}
|
|
1941
1935
|
const base = { kind: "navigate", id: "metric-navigate", label: "" };
|
|
@@ -2303,12 +2297,11 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2303
2297
|
// slots.header shares the header card with the actions (one card, actions
|
|
2304
2298
|
// top right) instead of rendering as an unframed block above it.
|
|
2305
2299
|
const metricItems = screen.metrics?.map((metric) => {
|
|
2306
|
-
const field =
|
|
2307
|
-
const
|
|
2308
|
-
const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
|
|
2300
|
+
const field = metric.field;
|
|
2301
|
+
const label = effectiveTranslate(metric.label);
|
|
2309
2302
|
const value = String(record[field] ?? "");
|
|
2310
2303
|
const testId = `kumiko-screen-projection-detail-metric-${field}`;
|
|
2311
|
-
const navigate =
|
|
2304
|
+
const navigate = metric.navigate;
|
|
2312
2305
|
const onPress = navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
|
|
2313
2306
|
? () => runMetricNavigate(nav, navigate, record, host)
|
|
2314
2307
|
: undefined;
|
|
@@ -2323,7 +2316,7 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
|
|
|
2323
2316
|
const statusBadge = header?.status !== undefined && StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : undefined;
|
|
2324
2317
|
// Lives in the band's subtitle row so it shares the metrics' horizontal inset.
|
|
2325
2318
|
const bandSubtitle = pageHeaderCompact && statusBadge !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-status-row", children: statusBadge }), headerSubtitleSlot] })) : (headerSubtitleSlot);
|
|
2326
|
-
const actionErrorBanner = actionError !== null && (
|
|
2319
|
+
const actionErrorBanner = actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] }));
|
|
2327
2320
|
const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(!pageHeaderCompact && statusBadge !== undefined && { status: statusBadge }), ...(hasHeaderActions && {
|
|
2328
2321
|
actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader, ...(screen.primaryAction !== undefined && {
|
|
2329
2322
|
preferredPrimaryId: screen.primaryAction,
|
|
@@ -45,9 +45,8 @@ export function synthesizeProjectionDetailScreen(screen) {
|
|
|
45
45
|
return section;
|
|
46
46
|
return {
|
|
47
47
|
...section,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
...(section.groups !== undefined && {
|
|
48
|
+
...("fields" in section && { fields: section.fields.map(forceReadOnly) }),
|
|
49
|
+
...("groups" in section && {
|
|
51
50
|
groups: section.groups.map((group) => ({
|
|
52
51
|
...group,
|
|
53
52
|
fields: group.fields.map(forceReadOnly),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useTranslation } from "../i18n.js";
|
|
3
3
|
import { usePrimitives } from "../primitives.js";
|
|
4
|
+
import { ErrorReference } from "./error-reference.js";
|
|
4
5
|
import { dispatcherErrorText } from "./write-failed-error.js";
|
|
5
6
|
export function QueryLoadingBanner() {
|
|
6
7
|
const { Banner } = usePrimitives();
|
|
@@ -10,5 +11,5 @@ export function QueryLoadingBanner() {
|
|
|
10
11
|
export function QueryErrorBanner({ error, translate, onRetry, }) {
|
|
11
12
|
const { Banner, Button } = usePrimitives();
|
|
12
13
|
const t = useTranslation();
|
|
13
|
-
return (
|
|
14
|
+
return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", actions: _jsx(Button, { variant: "secondary", size: "sm", onClick: onRetry, testId: "kumiko-screen-retry", children: t("kumiko.actions.retry") }), children: [dispatcherErrorText(error, translate), _jsx(ErrorReference, { requestId: error.requestId })] }));
|
|
14
15
|
}
|
package/dist/app/return-to.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext, useMemo } from "react";
|
|
3
3
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
4
|
+
import { requestLeave } from "../leave-guard.js";
|
|
4
5
|
import { useAppFeatures } from "./app-features-context.js";
|
|
5
6
|
import { useNav, } from "./nav.js";
|
|
6
7
|
import { lastSegment } from "./qn.js";
|
|
@@ -91,14 +92,17 @@ function returnParamsFor(nav, target, host) {
|
|
|
91
92
|
export function navigateWithReturnTo(nav, target, host, params) {
|
|
92
93
|
// Snapshot before navigating — nav.searchParams still describes the host.
|
|
93
94
|
const returnParams = returnParamsFor(nav, target, host);
|
|
94
|
-
nav.navigate(target);
|
|
95
95
|
const merged = {
|
|
96
96
|
...(params ?? {}),
|
|
97
97
|
...returnParams,
|
|
98
98
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
// One question for navigate + params: asked separately, a declined leave would still merge the params into the host URL.
|
|
100
|
+
requestLeave(() => {
|
|
101
|
+
nav.navigate(target);
|
|
102
|
+
if (Object.keys(merged).length > 0) {
|
|
103
|
+
nav.setSearchParams(merged);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
102
106
|
}
|
|
103
107
|
/** Jumps back to an already-validated returnTo target and restores the host
|
|
104
108
|
* search params carried in the same value. The host's own returnTo travels
|
|
@@ -109,10 +113,12 @@ export function navigateToReturn(nav, target) {
|
|
|
109
113
|
// target elsewhere must not inherit it.
|
|
110
114
|
const returnTo = raw === undefined ? undefined : splitReturnTo(raw);
|
|
111
115
|
const state = returnTo?.path === formatReturnTo(target) ? returnTo.state : NO_RETURN_STATE;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
requestLeave(() => {
|
|
117
|
+
nav.navigate(target);
|
|
118
|
+
// The explicit null drops this screen's own returnTo on nav impls whose
|
|
119
|
+
// navigate keeps the query.
|
|
120
|
+
nav.setSearchParams({ ...state, [RETURN_TO_PARAM]: state[RETURN_TO_PARAM] ?? null });
|
|
121
|
+
});
|
|
116
122
|
}
|
|
117
123
|
export function navigateToReturnOr(nav, target, fallback) {
|
|
118
124
|
if (target === undefined) {
|
|
@@ -12,7 +12,6 @@ function groupSectionsByTitle(sections) {
|
|
|
12
12
|
const byTitle = new Map();
|
|
13
13
|
for (const section of sections) {
|
|
14
14
|
const title = section.title ?? "config.secrets.section";
|
|
15
|
-
// kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec
|
|
16
15
|
byTitle.set(title, [...(byTitle.get(title) ?? []), ...section.fields]);
|
|
17
16
|
}
|
|
18
17
|
return [...byTitle].map(([title, fields]) => ({ title, fields }));
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
|
2
2
|
export type ErrorBoundaryFallbackProps = {
|
|
3
3
|
readonly error: Error;
|
|
4
|
+
/** Shown to the user so a support request can be matched to the report. */
|
|
5
|
+
readonly errorId: string;
|
|
4
6
|
readonly reset: () => void;
|
|
5
7
|
};
|
|
6
8
|
export type ErrorBoundaryProps = {
|
|
@@ -9,10 +11,15 @@ export type ErrorBoundaryProps = {
|
|
|
9
11
|
/** Prefix for the logged message, e.g. "screen" or "root". */
|
|
10
12
|
readonly scope: string;
|
|
11
13
|
/** Called after the default console.error log, e.g. to forward to telemetry. */
|
|
12
|
-
readonly onError?: (error: Error, info: ErrorInfo
|
|
14
|
+
readonly onError?: (error: Error, info: ErrorInfo, context: {
|
|
15
|
+
readonly errorId: string;
|
|
16
|
+
}) => void;
|
|
13
17
|
};
|
|
14
18
|
type ErrorBoundaryState = {
|
|
15
|
-
readonly
|
|
19
|
+
readonly caught: {
|
|
20
|
+
readonly error: Error;
|
|
21
|
+
readonly errorId: string;
|
|
22
|
+
} | undefined;
|
|
16
23
|
};
|
|
17
24
|
export declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
18
25
|
state: ErrorBoundaryState;
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { ERROR_BOUNDARY_LOG_MARKER } from "@cosmicdrift/kumiko-types/error-boundary";
|
|
2
2
|
import { Component } from "react";
|
|
3
|
+
import { createErrorId } from "./error-id.js";
|
|
3
4
|
function toError(thrown) {
|
|
4
5
|
return thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
5
6
|
}
|
|
6
7
|
export class ErrorBoundary extends Component {
|
|
7
|
-
state = {
|
|
8
|
+
state = { caught: undefined };
|
|
8
9
|
static getDerivedStateFromError(thrown) {
|
|
9
|
-
return { error: toError(thrown) };
|
|
10
|
+
return { caught: { error: toError(thrown), errorId: createErrorId() } };
|
|
10
11
|
}
|
|
11
12
|
componentDidCatch(thrown, info) {
|
|
12
13
|
const error = toError(thrown);
|
|
14
|
+
const errorId = this.state.caught?.errorId ?? createErrorId();
|
|
13
15
|
this.logCaught(error, info.componentStack);
|
|
14
16
|
try {
|
|
15
|
-
this.props.onError?.(error, info);
|
|
17
|
+
this.props.onError?.(error, info, { errorId });
|
|
16
18
|
}
|
|
17
19
|
catch (handlerError) {
|
|
18
20
|
// A throw escaping componentDidCatch hands the error to the next boundary up, so a broken telemetry hook would replace the screen card with the root fallback.
|
|
@@ -24,12 +26,12 @@ export class ErrorBoundary extends Component {
|
|
|
24
26
|
console.error(`[kumiko] ${this.props.scope} ${ERROR_BOUNDARY_LOG_MARKER}:`, error, detail);
|
|
25
27
|
}
|
|
26
28
|
reset = () => {
|
|
27
|
-
this.setState({
|
|
29
|
+
this.setState({ caught: undefined });
|
|
28
30
|
};
|
|
29
31
|
render() {
|
|
30
|
-
const {
|
|
31
|
-
if (
|
|
32
|
+
const { caught } = this.state;
|
|
33
|
+
if (caught === undefined)
|
|
32
34
|
return this.props.children;
|
|
33
|
-
return this.props.fallback({
|
|
35
|
+
return this.props.fallback({ ...caught, reset: this.reset });
|
|
34
36
|
}
|
|
35
37
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createErrorId(): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
// The server's client-error endpoint requires a uuid, so the Math.random fallback
|
|
3
|
+
// (runtimes without WebCrypto) keeps the uuid v4 shape.
|
|
4
|
+
export function createErrorId() {
|
|
5
|
+
const webCrypto = propertyOf(globalThis, "crypto");
|
|
6
|
+
const randomUUID = propertyOf(webCrypto, "randomUUID");
|
|
7
|
+
if (typeof randomUUID === "function") {
|
|
8
|
+
const id = Reflect.apply(randomUUID, webCrypto, []);
|
|
9
|
+
if (typeof id === "string")
|
|
10
|
+
return id;
|
|
11
|
+
}
|
|
12
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (placeholder) => {
|
|
13
|
+
const nibble = Math.floor(Math.random() * 16);
|
|
14
|
+
return (placeholder === "x" ? nibble : (nibble % 4) + 8).toString(16);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { ErrorReference } from "../app/error-reference.js";
|
|
3
4
|
import { usePrimitives } from "../primitives.js";
|
|
4
5
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
5
6
|
export function ListEmptyStateBody({ content, narrowed, defaultFilterActive = false, onShowAll, createButton, translate, }) {
|
|
@@ -11,5 +12,5 @@ export function ListEmptyStateBody({ content, narrowed, defaultFilterActive = fa
|
|
|
11
12
|
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: translate("kumiko.list.empty.defaultFiltered") }), onShowAll !== undefined && (_jsx(Button, { variant: "secondary", onClick: onShowAll, testId: "render-list-empty-show-all", children: translate("kumiko.list.empty.showAll") }))] }));
|
|
12
13
|
}
|
|
13
14
|
const description = content === undefined ? translate("kumiko.list.empty.hint") : content.description;
|
|
14
|
-
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (
|
|
15
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { children: content?.title ?? translate("kumiko.list.empty.title") }), description !== undefined && _jsx(Text, { variant: "small", children: description }), content?.action !== undefined && (_jsx(RenderEditActionButton, { action: content.action, Button: Button, Dialog: Dialog, onError: setActionError })), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-list-empty-state-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), createButton] }));
|
|
15
16
|
}
|
|
@@ -3,6 +3,7 @@ import { evalFieldCondition, normalizeListColumn, parseRefTarget, relatedListGro
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useAppFeatures } from "../app/app-features-context.js";
|
|
5
5
|
import { useClientActionRuntime } from "../app/client-actions.js";
|
|
6
|
+
import { ErrorReference } from "../app/error-reference.js";
|
|
6
7
|
import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
|
|
7
8
|
import { useNav } from "../app/nav.js";
|
|
8
9
|
import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
|
|
@@ -324,7 +325,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
324
325
|
(rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
|
|
325
326
|
const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: t("kumiko.widget.loading") })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
|
|
326
327
|
count: sortedRows.length,
|
|
327
|
-
}) })), emptyStateActionError !== null && (
|
|
328
|
+
}) })), emptyStateActionError !== null && (_jsxs(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: [emptyStateActionError.message, _jsx(ErrorReference, { requestId: emptyStateActionError.requestId })] })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
|
|
328
329
|
searchable: true,
|
|
329
330
|
searchValue: search,
|
|
330
331
|
onSearchChange: setSearch,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { type ErrorNotice } from "../app/error-reference.tsx";
|
|
2
3
|
import type { usePrimitives } from "../primitives.tsx";
|
|
3
4
|
import type { RenderEditAction } from "./render-edit-types.ts";
|
|
4
5
|
export declare function needsActionConfirm(action: Pick<RenderEditAction, "confirm" | "confirmRequired" | "style">): boolean;
|
|
@@ -20,5 +21,5 @@ export declare function RenderEditActionButton({ action, iconOnly, hideIcon, com
|
|
|
20
21
|
readonly compactLabel?: boolean;
|
|
21
22
|
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
22
23
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
23
|
-
readonly onError: (
|
|
24
|
+
readonly onError: (notice: ErrorNotice | null) => void;
|
|
24
25
|
}): ReactNode;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { errorNoticeOf } from "../app/error-reference.js";
|
|
3
4
|
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
4
5
|
// without an explicit confirm key — unless `confirmRequired` overrides it
|
|
5
6
|
// (schema-driven navigate/drawer actions, where the target form is itself
|
|
@@ -24,7 +25,7 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
|
|
|
24
25
|
await action.onPress();
|
|
25
26
|
}
|
|
26
27
|
catch (e) {
|
|
27
|
-
onError(
|
|
28
|
+
onError(errorNoticeOf(e));
|
|
28
29
|
}
|
|
29
30
|
finally {
|
|
30
31
|
setBusy(false);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { evalFieldCondition, isFieldsEditSection, normalizeEditField, PROJECTION_DETAIL_ENTITY, sectionFieldSpecs, } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
import { ErrorReference } from "../app/error-reference.js";
|
|
4
5
|
import { computeEditViewModel, groupIssuesByPath, relevantFieldIssues, zodErrorToFieldIssues, } from "../headless/index.js";
|
|
5
6
|
import { changedDerivedValues } from "./form-derivation-patch.js";
|
|
6
7
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
@@ -1519,7 +1520,7 @@ export function RenderEdit(props) {
|
|
|
1519
1520
|
setFormError(null);
|
|
1520
1521
|
setExtraDeleteIssues([]);
|
|
1521
1522
|
}, 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) }), rootIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-root", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`))), extraDeleteIssues.map((issue) => (_jsx(Text, { testId: "render-edit-form-error-extra", children: translate(issue.i18nKey, issue.params) }, `${issue.path}:${issue.i18nKey}`)))] })), formError === null &&
|
|
1522
|
-
rootIssues.map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (
|
|
1523
|
+
rootIssues.map((issue) => (_jsx(Banner, { variant: "error", testId: "render-edit-root-issue", children: _jsx(Text, { children: translate(issue.i18nKey, issue.params) }) }, issue.i18nKey))), extensionErrorKey !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-extension-error", children: _jsx(Text, { testId: "render-edit-extension-error-key", children: translate(extensionErrorKey) }) })), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), onDelete !== undefined && (_jsx(Dialog, { open: confirmDeleteOpen, onOpenChange: setConfirmDeleteOpen, title: translate("kumiko.actions.delete-confirm"), confirmLabel: translate("kumiko.actions.delete"), variant: "danger", onConfirm: async () => {
|
|
1523
1524
|
const rejection = await onDelete();
|
|
1524
1525
|
// A rejected delete (e.g. a preDelete hook) carries its reason in
|
|
1525
1526
|
// the field issues; the top-level key is only "validation failed".
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
+
import { type ErrorNotice } from "../app/error-reference.tsx";
|
|
2
3
|
import type { ActionMenuItemSpec, usePrimitives } from "../primitives.tsx";
|
|
3
4
|
import type { RenderEditAction } from "./render-edit-types.ts";
|
|
4
5
|
export declare function useActionMenuItems({ Dialog, onError, }: {
|
|
5
6
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
6
|
-
readonly onError: (
|
|
7
|
+
readonly onError: (notice: ErrorNotice | null) => void;
|
|
7
8
|
}): {
|
|
8
9
|
readonly toMenuItem: (action: RenderEditAction) => ActionMenuItemSpec;
|
|
9
10
|
readonly confirmDialog: ReactNode;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { errorNoticeOf } from "../app/error-reference.js";
|
|
3
4
|
import { needsActionConfirm, RenderEditActionConfirmDialog } from "./render-edit-action-button.js";
|
|
4
5
|
// A menu item has no button of its own to carry RenderEditActionButton's busy
|
|
5
6
|
// and confirm state, so this hook owns both for every item it creates.
|
|
@@ -14,7 +15,7 @@ export function useActionMenuItems({ Dialog, onError, }) {
|
|
|
14
15
|
await action.onPress();
|
|
15
16
|
}
|
|
16
17
|
catch (e) {
|
|
17
|
-
onError(
|
|
18
|
+
onError(errorNoticeOf(e));
|
|
18
19
|
}
|
|
19
20
|
finally {
|
|
20
21
|
setBusyActionId(null);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import * as z from "zod";
|
|
4
|
+
import { ErrorReference } from "../app/error-reference.js";
|
|
4
5
|
import { isPresent, REQUIRED_FIELD_I18N_KEY } from "../app/form-schema.js";
|
|
5
6
|
import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
6
7
|
import { I18N_KEY_PARAM } from "../headless/index.js";
|
|
@@ -72,7 +73,10 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
72
73
|
// refine, a hidden field).
|
|
73
74
|
const issuePaths = (result.error.details?.fields ?? []).map((i) => i.path);
|
|
74
75
|
setError(hasIssueWithoutVisibleField(issuePaths, section.fields)
|
|
75
|
-
?
|
|
76
|
+
? {
|
|
77
|
+
message: dispatcherErrorText(result.error, effectiveTranslate),
|
|
78
|
+
...(result.error.requestId !== undefined && { requestId: result.error.requestId }),
|
|
79
|
+
}
|
|
76
80
|
: null);
|
|
77
81
|
}
|
|
78
82
|
finally {
|
|
@@ -103,7 +107,7 @@ export function WriteFormSection({ section, featureName, translate, hideTitle, o
|
|
|
103
107
|
controller.setErrors(remaining);
|
|
104
108
|
setError(null);
|
|
105
109
|
}, GridCell: GridCell, featureName: featureName, allIssues: snapshot.errors, valueDisplay: "form", row: snapshot.values, ...(hideTitle !== true &&
|
|
106
|
-
section.title !== undefined && { sectionTitle: section.title }) }, field.field))) }), error !== null && (
|
|
110
|
+
section.title !== undefined && { sectionTitle: section.title }) }, field.field))) }), error !== null && (_jsxs(Banner, { variant: "error", testId: "write-form-section-error", children: [error.message, _jsx(ErrorReference, { requestId: error.requestId })] })), onFooterAction === undefined && (_jsx(Card, { options: { framed: false, footerBordered: false, footerInset: false }, slots: { footer: submitButton }, testId: "write-form-section-submit-row" }))] }));
|
|
107
111
|
// Section always flattens to a borderless divider when rendered inside
|
|
108
112
|
// RenderEdit's own <Form> in tabs mode (hideTitle) — an unframed Card panel
|
|
109
113
|
// stands in, like render-edit.tsx's tabs-mode fields/extension branches.
|
|
@@ -464,7 +464,7 @@ export function computeEditViewModel(input) {
|
|
|
464
464
|
// better than the single-column default a stacked form keeps. Applies to
|
|
465
465
|
// entityEdit tabs as well since fw#3134, not just projectionDetail.
|
|
466
466
|
const defaultColumns = screen.layout.mode === "tabs" ? 2 : 1;
|
|
467
|
-
const groups = sectionSpec.groups
|
|
467
|
+
const groups = ("groups" in sectionSpec ? sectionSpec.groups : undefined)
|
|
468
468
|
?.map((group) => ({ ...group, fields: group.fields.filter((f) => !isHiddenByExclusion(f)) }))
|
|
469
469
|
.filter((group) => group.fields.length > 0)
|
|
470
470
|
.map((group) => ({
|
|
@@ -157,7 +157,7 @@ export type EditFieldViewModel = {
|
|
|
157
157
|
readonly rows?: number;
|
|
158
158
|
};
|
|
159
159
|
/** From TextFieldDef.format — `password` makes the renderer mask the input. */
|
|
160
|
-
readonly format?: "email" | "url" | "phone" | "password" | "month";
|
|
160
|
+
readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
|
|
161
161
|
/** From TextFieldDef.autoComplete — HTML autocomplete token for the text input. */
|
|
162
162
|
readonly autoComplete?: string;
|
|
163
163
|
/** From TextFieldDef.writeOnly — value is `true` (set) / `null` (empty), never plaintext. */
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -156,6 +156,9 @@ export const kumikoDefaultTranslations = {
|
|
|
156
156
|
"kumiko.screen-access-denied.title": "You do not have access to this page.",
|
|
157
157
|
"kumiko.screen-missing.home": "Go to start page",
|
|
158
158
|
"kumiko.screen-error.title": "This screen failed to render.",
|
|
159
|
+
"kumiko.error.reference": "Error ID: {id}",
|
|
160
|
+
"kumiko.error.copyReference": "Copy",
|
|
161
|
+
"kumiko.error.referenceCopied": "Copied!",
|
|
159
162
|
"kumiko.screen-error.detail": "Screen: {screen}. {message}",
|
|
160
163
|
"kumiko.screen-error.screen": "Screen: {screen}",
|
|
161
164
|
"kumiko.widget.upload.uploading": "Uploading…",
|
|
@@ -280,6 +283,7 @@ export const kumikoDefaultTranslations = {
|
|
|
280
283
|
"errors.validation.invalid_format.date": "Invalid date, e.g. 2026-10-20.",
|
|
281
284
|
"errors.validation.invalid_format.datetime": "Invalid date and time, e.g. 2026-10-20T14:30.",
|
|
282
285
|
"errors.validation.invalid_format.uuid": "Invalid ID, e.g. 123e4567-e89b-12d3-a456-426614174000.",
|
|
286
|
+
"errors.validation.invalid_format.iban": "Invalid IBAN, e.g. DE89 3704 0044 0532 0130 00.",
|
|
283
287
|
"errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
|
|
284
288
|
"errors.validation.unrecognized_keys": "Unknown fields.",
|
|
285
289
|
"errors.validation.invalid_union": "Invalid value.",
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-
|
|
|
14
14
|
export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens.tsx";
|
|
15
15
|
export type { DashboardBodyProps, DashboardBodyProviderProps } from "./app/dashboard-body.tsx";
|
|
16
16
|
export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body.tsx";
|
|
17
|
+
export { type ErrorNotice, ErrorReference, errorNoticeOf } from "./app/error-reference.tsx";
|
|
17
18
|
export type { ExtensionFormRegistry, ExtensionFormSubmitHandler, ExtensionSubmitContext, ExtensionSubmitResult, } from "./app/extension-form-submit.tsx";
|
|
18
19
|
export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/extension-form-submit.tsx";
|
|
19
20
|
export type { ExtensionSectionComponent, ExtensionSectionProps, ExtensionSectionsMap, ExtensionSectionsProviderProps, } from "./app/extension-sections.tsx";
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ export { CONTENT_EDITOR_ELEMENT_ID, ContentEditorsProvider, TextareaContentEdito
|
|
|
17
17
|
export { ContentPreview, substituteVariables } from "./app/content-preview.js";
|
|
18
18
|
export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens.js";
|
|
19
19
|
export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body.js";
|
|
20
|
+
export { ErrorReference, errorNoticeOf } from "./app/error-reference.js";
|
|
20
21
|
export { ExtensionFormRegistryProvider, useExtensionFormSubmit, } from "./app/extension-form-submit.js";
|
|
21
22
|
export { ExtensionSectionsProvider, extensionSectionName, useExtensionSectionComponent, } from "./app/extension-sections.js";
|
|
22
23
|
export { isAppSchema, toAppSchema } from "./app/feature-schema.js";
|
package/dist/leave-guard.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
const sources = new Set();
|
|
3
3
|
let confirmer;
|
|
4
|
+
// While a confirmed (or unguarded) leave runs, navigation calls nested inside it must not ask a second time.
|
|
5
|
+
let runningLeaveDepth = 0;
|
|
4
6
|
export function registerUnsavedInputSource(source) {
|
|
5
7
|
sources.add(source);
|
|
6
8
|
return () => {
|
|
@@ -23,12 +25,21 @@ export function setLeaveConfirmer(next) {
|
|
|
23
25
|
/** Runs `proceed` right away when nothing is dirty (or no dialog host is
|
|
24
26
|
* mounted); otherwise asks the user first. */
|
|
25
27
|
export function requestLeave(proceed, cancel = () => { }) {
|
|
26
|
-
if (confirmer === undefined || !hasUnsavedInput()) {
|
|
27
|
-
proceed
|
|
28
|
+
if (runningLeaveDepth > 0 || confirmer === undefined || !hasUnsavedInput()) {
|
|
29
|
+
runLeave(proceed);
|
|
28
30
|
// skip: nothing to lose, no question needed
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
|
-
confirmer({ proceed, cancel });
|
|
33
|
+
confirmer({ proceed: () => runLeave(proceed), cancel });
|
|
34
|
+
}
|
|
35
|
+
function runLeave(proceed) {
|
|
36
|
+
runningLeaveDepth += 1;
|
|
37
|
+
try {
|
|
38
|
+
proceed();
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
runningLeaveDepth -= 1;
|
|
42
|
+
}
|
|
32
43
|
}
|
|
33
44
|
/** `isDirty` is read lazily, at guard time, so a form can report "clean"
|
|
34
45
|
* synchronously after a successful save — before React re-renders. */
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const CLIENT_ERRORS_PATH = "/api/client-errors";
|
|
2
|
+
export declare const MAX_CLIENT_ERROR_REPORTS = 5;
|
|
3
|
+
export type ClientErrorKind = "render" | "unhandled";
|
|
4
|
+
export type ClientErrorReport = {
|
|
5
|
+
readonly errorId?: string;
|
|
6
|
+
readonly kind: ClientErrorKind;
|
|
7
|
+
readonly error: unknown;
|
|
8
|
+
readonly screen?: string;
|
|
9
|
+
};
|
|
10
|
+
export type ClientErrorReporter = {
|
|
11
|
+
readonly report: (report: ClientErrorReport) => void;
|
|
12
|
+
/** Starts forwarding window error/unhandledrejection events; returns the stop function. */
|
|
13
|
+
readonly installGlobalHandlers: () => () => void;
|
|
14
|
+
};
|
|
15
|
+
export declare function createClientErrorReporter(): ClientErrorReporter;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createErrorId } from "../../components/error-id.js";
|
|
2
|
+
import { CSRF_HEADER_NAME, readCsrfToken } from "../../live/csrf.js";
|
|
3
|
+
export const CLIENT_ERRORS_PATH = "/api/client-errors";
|
|
4
|
+
export const MAX_CLIENT_ERROR_REPORTS = 5;
|
|
5
|
+
// Mirrors the server's strict schema; longer values would get the whole report rejected.
|
|
6
|
+
const LIMITS = { message: 500, stack: 4000, screen: 200, path: 300 };
|
|
7
|
+
function clip(value, max) {
|
|
8
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
9
|
+
}
|
|
10
|
+
function toError(thrown) {
|
|
11
|
+
return thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
12
|
+
}
|
|
13
|
+
// One handler pair per page: a repeated createKumikoApp (HMR, tests) must replace, not stack, the listeners.
|
|
14
|
+
let removeActiveGlobalHandlers;
|
|
15
|
+
export function createClientErrorReporter() {
|
|
16
|
+
const seenMessages = new Set();
|
|
17
|
+
function send(body) {
|
|
18
|
+
// skip: no fetch (native shell without polyfill) means nowhere to report to
|
|
19
|
+
if (typeof fetch !== "function")
|
|
20
|
+
return;
|
|
21
|
+
const csrf = readCsrfToken();
|
|
22
|
+
try {
|
|
23
|
+
void fetch(CLIENT_ERRORS_PATH, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
keepalive: true,
|
|
26
|
+
credentials: "same-origin",
|
|
27
|
+
headers: {
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
...(csrf !== undefined && { [CSRF_HEADER_NAME]: csrf }),
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify(body),
|
|
32
|
+
}).catch(() => undefined);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Reporting must never throw into the error path it observes.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function report({ errorId, kind, error, screen }) {
|
|
39
|
+
// skip: per-page cap keeps an error loop from flooding the endpoint
|
|
40
|
+
if (seenMessages.size >= MAX_CLIENT_ERROR_REPORTS)
|
|
41
|
+
return;
|
|
42
|
+
const { message, stack } = toError(error);
|
|
43
|
+
// skip: duplicate of an already reported message
|
|
44
|
+
if (seenMessages.has(message))
|
|
45
|
+
return;
|
|
46
|
+
seenMessages.add(message);
|
|
47
|
+
const path = typeof location === "undefined" ? undefined : location.pathname;
|
|
48
|
+
send({
|
|
49
|
+
errorId: errorId ?? createErrorId(),
|
|
50
|
+
kind,
|
|
51
|
+
message: clip(message, LIMITS.message),
|
|
52
|
+
...(stack !== undefined && stack !== "" && { stack: clip(stack, LIMITS.stack) }),
|
|
53
|
+
...(screen !== undefined && { screen: clip(screen, LIMITS.screen) }),
|
|
54
|
+
...(path !== undefined && { path: clip(path, LIMITS.path) }),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function installGlobalHandlers() {
|
|
58
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") {
|
|
59
|
+
return () => undefined;
|
|
60
|
+
}
|
|
61
|
+
const onError = (event) => {
|
|
62
|
+
// skip: resource-load failures arrive as plain Events without an error payload
|
|
63
|
+
if (event.error === undefined && !event.message)
|
|
64
|
+
return;
|
|
65
|
+
report({ kind: "unhandled", error: event.error ?? new Error(event.message) });
|
|
66
|
+
};
|
|
67
|
+
const onRejection = (event) => {
|
|
68
|
+
report({ kind: "unhandled", error: event.reason });
|
|
69
|
+
};
|
|
70
|
+
removeActiveGlobalHandlers?.();
|
|
71
|
+
window.addEventListener("error", onError);
|
|
72
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
73
|
+
const remove = () => {
|
|
74
|
+
window.removeEventListener("error", onError);
|
|
75
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
76
|
+
if (removeActiveGlobalHandlers === remove)
|
|
77
|
+
removeActiveGlobalHandlers = undefined;
|
|
78
|
+
};
|
|
79
|
+
removeActiveGlobalHandlers = remove;
|
|
80
|
+
return remove;
|
|
81
|
+
}
|
|
82
|
+
return { report, installGlobalHandlers };
|
|
83
|
+
}
|
|
@@ -9,11 +9,19 @@ export declare function buildNavProviderMaps(clientFeatures: readonly ClientFeat
|
|
|
9
9
|
readonly navProviders: ReadonlyMap<string, TreeChildrenSubscribe>;
|
|
10
10
|
readonly navEntities: ReadonlyMap<string, readonly string[]>;
|
|
11
11
|
};
|
|
12
|
-
export type
|
|
12
|
+
export type BoundaryErrorContext = {
|
|
13
|
+
readonly errorId: string;
|
|
14
|
+
/** Qualified name of the screen whose boundary caught the error; absent for the root boundary. */
|
|
15
|
+
readonly screen?: string;
|
|
16
|
+
};
|
|
17
|
+
export type BoundaryErrorHandler = (error: Error, info: ErrorInfo, context: BoundaryErrorContext) => void;
|
|
13
18
|
export type CreateKumikoAppOptions = {
|
|
14
19
|
/** Called when the screen or root error boundary catches an error, after it
|
|
15
20
|
* was logged with console.error — attach telemetry here. */
|
|
16
21
|
readonly onError?: BoundaryErrorHandler;
|
|
22
|
+
/** POST caught render errors and unhandled window errors to `/api/client-errors`
|
|
23
|
+
* (default true). A custom `onError` still runs in addition. */
|
|
24
|
+
readonly reportClientErrors?: boolean;
|
|
17
25
|
/** App schema. Accepts AppSchema (multi-feature) or the legacy
|
|
18
26
|
* FeatureSchema (single-feature) — toAppSchema() normalizes internally.
|
|
19
27
|
*
|
|
@@ -12,6 +12,7 @@ import { useBrowserTokensApi } from "../tokens.js";
|
|
|
12
12
|
import { UpdateChecker } from "../version/update-checker.js";
|
|
13
13
|
import { AppSchemaFetchBoot } from "./app-schema-boot.js";
|
|
14
14
|
import { createBrowserLocaleResolver } from "./browser-locale.js";
|
|
15
|
+
import { createClientErrorReporter } from "./client-error-reporter.js";
|
|
15
16
|
import { stackWrappers } from "./client-plugin.js";
|
|
16
17
|
import { WebDashboardBody } from "./dashboard-body.js";
|
|
17
18
|
import { DocumentLangSync } from "./document-lang-sync.js";
|
|
@@ -338,8 +339,19 @@ export function createKumikoApp(options = {}) {
|
|
|
338
339
|
}
|
|
339
340
|
const localeResolver = options.locale ?? createBrowserLocaleResolver();
|
|
340
341
|
const navAdapter = options.navAdapter ?? useBrowserNavApi;
|
|
342
|
+
const reporter = options.reportClientErrors === false ? undefined : createClientErrorReporter();
|
|
343
|
+
reporter?.installGlobalHandlers();
|
|
344
|
+
const handleBoundaryError = (error, info, context) => {
|
|
345
|
+
reporter?.report({
|
|
346
|
+
kind: "render",
|
|
347
|
+
error,
|
|
348
|
+
errorId: context.errorId,
|
|
349
|
+
...(context.screen !== undefined && { screen: context.screen }),
|
|
350
|
+
});
|
|
351
|
+
options.onError?.(error, info, context);
|
|
352
|
+
};
|
|
341
353
|
const root = createRoot(container);
|
|
342
|
-
root.render(_jsx(ErrorBoundary, { scope: "root",
|
|
354
|
+
root.render(_jsx(ErrorBoundary, { scope: "root", onError: handleBoundaryError, fallback: ({ errorId }) => _jsx(RootErrorFallback, { errorId: errorId }), children: _jsx(BoundaryErrorHandlerContext.Provider, { value: handleBoundaryError, children: _jsx(KumikoAppRoot, { initialApp: initialApp, screenQn: options.screenQn, ...(options.schemaUrl !== undefined && { schemaUrl: options.schemaUrl }), clientFeatures: clientFeatures, providers: providers, gates: gates, customScreens: customScreens, columnRenderers: columnRenderers, extensionSectionComponents: extensionSectionComponents, clientActions: clientActions, contentEditors: contentEditors, resolvers: resolvers, dispatcher: dispatcher, sessionEndedSignal: sessionEndedSignal, draftStorage: draftStorage, primitives: primitives, liveEvents: liveEvents, localeResolver: localeResolver, navAdapter: navAdapter, screenWidth: options.screenWidth, ...(options.translate !== undefined && { translate: options.translate }), ...(options.onRowClick !== undefined && { onRowClick: options.onRowClick }), ...(options.shell !== undefined && { shell: options.shell }) }) }) }));
|
|
343
355
|
return { root };
|
|
344
356
|
}
|
|
345
357
|
// TokensBoot uses the browser-backed TokensApi hook (class-based
|
|
@@ -494,5 +506,7 @@ function RoutedScreen({ app, fallbackQn, translate, onRowClick, }) {
|
|
|
494
506
|
}
|
|
495
507
|
// KumikoScreen takes a single-feature schema: the feature owning the active screen.
|
|
496
508
|
const schemaForScreen = feature;
|
|
497
|
-
return (_jsx(ErrorBoundary, { scope: "screen", ...(onBoundaryError !== undefined && {
|
|
509
|
+
return (_jsx(ErrorBoundary, { scope: "screen", ...(onBoundaryError !== undefined && {
|
|
510
|
+
onError: (error, info, context) => onBoundaryError(error, info, { ...context, screen: qn }),
|
|
511
|
+
}), fallback: ({ error, errorId, reset }) => (_jsx(ScreenErrorFallback, { screen: qn, error: error, errorId: errorId, onReset: reset })), children: _jsx(KumikoScreen, { schema: schemaForScreen, qn: qn, ...(translate !== undefined && { translate }), ...(entityId !== undefined && { entityId }), onRowClick: effectiveOnRowClick, ...(effectiveOnCopyLink !== undefined && { onCopyLink: effectiveOnCopyLink }) }) }, `${qn}/${entityId ?? ""}`));
|
|
498
512
|
}
|
|
@@ -3,6 +3,7 @@ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
|
3
3
|
import { DASHBOARD_STAT_TONES } from "@cosmicdrift/kumiko-types/screen";
|
|
4
4
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
5
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
6
|
+
import { ErrorReference } from "../../app/error-reference.js";
|
|
6
7
|
import { currencyDecimals, EMPTY_VALUE_PLACEHOLDER } from "../../headless/index.js";
|
|
7
8
|
import { dispatcherErrorText, EmbeddedScreenProvider, evalVisibleWhen, extensionSectionName, HeaderActionsBar, KumikoScreen, lastSegment, useEmbeddedScreen, useExtensionSectionComponent, useLocale, useNav, usePageHeaderCompact, usePageHeaderSlotAvailable, usePrimitives, useQuery, useRecordActions, useTranslation, useUserRoles, } from "../../index.js";
|
|
8
9
|
import { cn } from "../lib/cn.js";
|
|
@@ -865,7 +866,7 @@ export function WebDashboardBody({ featureName, screen, translate, }) {
|
|
|
865
866
|
// The shell header owns the title; the badge and the actions sit in it there.
|
|
866
867
|
const badgeInHeader = scopeBadge !== null && usesPageHeaderSlot;
|
|
867
868
|
const showPageHeader = usesPageHeaderSlot && (badgeInHeader || recordTitle !== undefined || headerActionsBar !== null);
|
|
868
|
-
return (_jsx(PanelLoadedContext.Provider, { value: reportLoaded, children: _jsx(DashboardQueryContext.Provider, { value: queryRuntime, children: _jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [showPageHeader && (_jsx(PageHeader, { ...(recordTitle !== undefined && { title: recordTitle }), ...(badgeInHeader && { status: scopeBadge }), ...(headerActionsBar !== null && { actions: headerActionsBar }) })), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [!usesPageHeaderSlot && recordTitle !== undefined && (_jsx(Heading, { variant: "page", testId: `dashboard-${screen.id}-record-title`, children: recordTitle })), !badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [!usesPageHeaderSlot && headerActionsBar, screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), actionError !== null && (
|
|
869
|
+
return (_jsx(PanelLoadedContext.Provider, { value: reportLoaded, children: _jsx(DashboardQueryContext.Provider, { value: queryRuntime, children: _jsxs(PageSection, { className: "flex flex-col gap-4", testId: `dashboard-${screen.id}`, children: [showPageHeader && (_jsx(PageHeader, { ...(recordTitle !== undefined && { title: recordTitle }), ...(badgeInHeader && { status: scopeBadge }), ...(headerActionsBar !== null && { actions: headerActionsBar }) })), _jsxs("div", { className: "flex flex-wrap items-end justify-between gap-3", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [!usesPageHeaderSlot && recordTitle !== undefined && (_jsx(Heading, { variant: "page", testId: `dashboard-${screen.id}-record-title`, children: recordTitle })), !badgeInHeader && scopeBadge, picker] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [!usesPageHeaderSlot && headerActionsBar, screen.showUpdatedAt !== false && (_jsx("span", { "data-testid": `dashboard-${screen.id}-updated-at`, className: "text-xs tabular-nums text-muted-foreground", children: t("kumiko.dashboard.updated-at", { time: formatDateTime(loadedAtMs) }) })), rangeControl] })] }), actionError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-action-error", children: [actionError.message, _jsx(ErrorReference, { requestId: actionError.requestId })] })), recordQuery.error !== null && recordRow === null && (_jsxs("div", { role: "alert", "data-testid": `dashboard-${screen.id}-record-error`, className: "flex flex-col items-start gap-2 text-sm", children: [_jsx("span", { className: "text-status-bad", children: dispatcherErrorText(recordQuery.error, t) }), _jsx(Button, { variant: "secondary", onClick: () => void refetchRecord(), children: t("kumiko.dashboard.panel.error.retry") })] })), description !== undefined && (_jsx(Text, { variant: "muted", testId: `dashboard-${screen.id}-description`, children: description })), scope?.notice !== undefined && (_jsx(Banner, { variant: "info", testId: `dashboard-${screen.id}-scope-notice`, children: effectiveTranslate(scope.notice) })), _jsxs(VisibilityQueryContext.Provider, { value: gateResults.results, children: [_jsx(VisibilityGateQueries, { gates: visibilityGates, onResult: gateResults.report, onForget: gateResults.forget }), _jsx("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", children: screen.panels.map((panel) => {
|
|
869
870
|
if (panel.kind === "screen") {
|
|
870
871
|
return (_jsx(ScreenPanelTile, { panel: panel, featureName: featureName, translate: effectiveTranslate }, panel.id));
|
|
871
872
|
}
|
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { ROOT_ERROR_FALLBACK_TEST_ID } from "@cosmicdrift/kumiko-types/error-boundary";
|
|
3
|
-
|
|
3
|
+
import { Check, Copy } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
// Renders outside every provider and possibly without loaded CSS, so no i18n/theme/primitives: plain DOM, inline text and inline styles (lucide icons are self-contained SVG).
|
|
4
6
|
const ROOT_ERROR_TEXTS = {
|
|
5
|
-
de: {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
de: {
|
|
8
|
+
title: "Etwas ist schiefgelaufen.",
|
|
9
|
+
reload: "Seite neu laden",
|
|
10
|
+
reference: "Fehler-ID",
|
|
11
|
+
copy: "Kopieren",
|
|
12
|
+
copied: "Kopiert!",
|
|
13
|
+
},
|
|
14
|
+
en: {
|
|
15
|
+
title: "Something went wrong.",
|
|
16
|
+
reload: "Reload page",
|
|
17
|
+
reference: "Error ID",
|
|
18
|
+
copy: "Copy",
|
|
19
|
+
copied: "Copied!",
|
|
20
|
+
},
|
|
21
|
+
es: {
|
|
22
|
+
title: "Algo salió mal.",
|
|
23
|
+
reload: "Recargar la página",
|
|
24
|
+
reference: "ID de error",
|
|
25
|
+
copy: "Copiar",
|
|
26
|
+
copied: "¡Copiado!",
|
|
27
|
+
},
|
|
8
28
|
};
|
|
9
29
|
function rootErrorTexts() {
|
|
10
30
|
const language = typeof navigator === "undefined" ? "en" : navigator.language;
|
|
@@ -15,7 +35,60 @@ function rootErrorTexts() {
|
|
|
15
35
|
return ROOT_ERROR_TEXTS.es;
|
|
16
36
|
return ROOT_ERROR_TEXTS.en;
|
|
17
37
|
}
|
|
18
|
-
|
|
38
|
+
const containerStyle = {
|
|
39
|
+
boxSizing: "border-box",
|
|
40
|
+
minHeight: "100vh",
|
|
41
|
+
display: "flex",
|
|
42
|
+
flexDirection: "column",
|
|
43
|
+
alignItems: "center",
|
|
44
|
+
justifyContent: "center",
|
|
45
|
+
gap: "16px",
|
|
46
|
+
padding: "24px",
|
|
47
|
+
textAlign: "center",
|
|
48
|
+
fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif",
|
|
49
|
+
color: "#1a1a1a",
|
|
50
|
+
background: "#ffffff",
|
|
51
|
+
};
|
|
52
|
+
const titleStyle = { margin: 0, fontSize: "20px", fontWeight: 600 };
|
|
53
|
+
const referenceStyle = {
|
|
54
|
+
display: "flex",
|
|
55
|
+
alignItems: "center",
|
|
56
|
+
flexWrap: "wrap",
|
|
57
|
+
justifyContent: "center",
|
|
58
|
+
gap: "8px",
|
|
59
|
+
fontSize: "13px",
|
|
60
|
+
color: "#555555",
|
|
61
|
+
};
|
|
62
|
+
const errorIdStyle = { whiteSpace: "nowrap" };
|
|
63
|
+
const buttonStyle = {
|
|
64
|
+
display: "inline-flex",
|
|
65
|
+
alignItems: "center",
|
|
66
|
+
gap: "6px",
|
|
67
|
+
padding: "6px 12px",
|
|
68
|
+
fontFamily: "inherit",
|
|
69
|
+
fontSize: "14px",
|
|
70
|
+
fontWeight: 500,
|
|
71
|
+
lineHeight: "20px",
|
|
72
|
+
borderRadius: "6px",
|
|
73
|
+
border: "1px solid #e4e4e7",
|
|
74
|
+
background: "#ffffff",
|
|
75
|
+
color: "#1a1a1a",
|
|
76
|
+
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
|
77
|
+
cursor: "pointer",
|
|
78
|
+
};
|
|
79
|
+
export function RootErrorFallback({ errorId }) {
|
|
19
80
|
const texts = rootErrorTexts();
|
|
20
|
-
|
|
81
|
+
const [copied, setCopied] = useState(false);
|
|
82
|
+
async function copyErrorId() {
|
|
83
|
+
if (errorId === undefined)
|
|
84
|
+
return;
|
|
85
|
+
try {
|
|
86
|
+
await navigator.clipboard.writeText(errorId);
|
|
87
|
+
setCopied(true);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Clipboard denied or unavailable: the id stays visible and selectable.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return (_jsxs("div", { role: "alert", "data-testid": ROOT_ERROR_FALLBACK_TEST_ID, style: containerStyle, children: [_jsx("p", { style: titleStyle, children: texts.title }), errorId !== undefined && (_jsxs("div", { style: referenceStyle, "data-testid": `${ROOT_ERROR_FALLBACK_TEST_ID}-reference`, children: [_jsxs("span", { children: [texts.reference, ": ", _jsx("span", { style: errorIdStyle, children: errorId })] }), _jsxs("button", { type: "button", style: buttonStyle, "data-testid": `${ROOT_ERROR_FALLBACK_TEST_ID}-copy`, onClick: () => void copyErrorId(), children: [copied ? (_jsx(Check, { size: 14, "aria-hidden": "true" })) : (_jsx(Copy, { size: 14, "aria-hidden": "true" })), copied ? texts.copied : texts.copy] })] })), _jsx("button", { type: "button", style: buttonStyle, onClick: () => window.location.reload(), children: texts.reload })] }));
|
|
21
94
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
-
export declare function ScreenErrorFallback({ screen, error, onReset, }: {
|
|
2
|
+
export declare function ScreenErrorFallback({ screen, error, errorId, onReset, }: {
|
|
3
3
|
readonly screen: string;
|
|
4
4
|
readonly error: Error;
|
|
5
|
+
readonly errorId: string;
|
|
5
6
|
readonly onReset: () => void;
|
|
6
7
|
}): ReactNode;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { SCREEN_ERROR_FALLBACK_TEST_ID } from "@cosmicdrift/kumiko-types/error-boundary";
|
|
3
|
+
import { ErrorReference } from "../../app/error-reference.js";
|
|
3
4
|
import { usePrimitives, useTranslation } from "../../index.js";
|
|
4
5
|
// Opt-in: a bundle that leaves NODE_ENV unset must not leak error messages to end users.
|
|
5
6
|
function showsErrorDetails() {
|
|
@@ -8,10 +9,10 @@ function showsErrorDetails() {
|
|
|
8
9
|
const nodeEnv = process.env.NODE_ENV;
|
|
9
10
|
return nodeEnv !== undefined && nodeEnv !== "" && nodeEnv !== "production";
|
|
10
11
|
}
|
|
11
|
-
export function ScreenErrorFallback({ screen, error, onReset, }) {
|
|
12
|
+
export function ScreenErrorFallback({ screen, error, errorId, onReset, }) {
|
|
12
13
|
const { Banner, Button } = usePrimitives();
|
|
13
14
|
const t = useTranslation();
|
|
14
|
-
return (
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
return (_jsxs(Banner, { variant: "error", padded: true, testId: SCREEN_ERROR_FALLBACK_TEST_ID, title: t("kumiko.screen-error.title"), actions: _jsx(Button, { variant: "secondary", onClick: onReset, testId: `${SCREEN_ERROR_FALLBACK_TEST_ID}-reset`, children: t("kumiko.actions.reload") }), children: [showsErrorDetails()
|
|
16
|
+
? t("kumiko.screen-error.detail", { screen, message: error.message })
|
|
17
|
+
: t("kumiko.screen-error.screen", { screen }), _jsx(ErrorReference, { requestId: errorId, testId: `${SCREEN_ERROR_FALLBACK_TEST_ID}-reference` })] }));
|
|
17
18
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import { useCallback, useMemo } from "react";
|
|
4
|
-
import { useNav, usePrimitives, useTranslation } from "../../index.js";
|
|
4
|
+
import { requestLeave, useNav, usePrimitives, useTranslation } from "../../index.js";
|
|
5
5
|
import { clearTargetSearchParams, parseTargetFromSearchParams } from "./target-url.js";
|
|
6
6
|
function EditorPanelInner({ target, resolvers, onClose, }) {
|
|
7
7
|
const { JsonView } = usePrimitives();
|
|
@@ -20,7 +20,7 @@ export function EditorPanel({ resolvers }) {
|
|
|
20
20
|
// sind (nav-Impl liefert plain-record für genau diesen check).
|
|
21
21
|
const target = useMemo(() => parseTargetFromSearchParams(nav.searchParams), [nav.searchParams]);
|
|
22
22
|
const handleClose = useCallback(() => {
|
|
23
|
-
nav.setSearchParams(clearTargetSearchParams(nav.searchParams));
|
|
23
|
+
requestLeave(() => nav.setSearchParams(clearTargetSearchParams(nav.searchParams)));
|
|
24
24
|
}, [nav]);
|
|
25
25
|
return (_jsx("div", { "data-kumiko-layout": "editor-main", className: "flex-1 overflow-y-auto", children: target === undefined ? (_jsx(EditorEmptyState, {})) : (_jsx(EditorPanelInner, { target: target, resolvers: resolvers, onClose: handleClose })) }));
|
|
26
26
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// die URL via nav.setSearchParams UND ruft den globalen dispatchTarget für
|
|
17
17
|
// Test-Listener-Kompatibilität.
|
|
18
18
|
import { useCallback } from "react";
|
|
19
|
-
import { useNav } from "../../index.js";
|
|
19
|
+
import { requestLeave, useNav } from "../../index.js";
|
|
20
20
|
import { serializeTarget } from "./target-url.js";
|
|
21
21
|
let testListener;
|
|
22
22
|
const subscribers = new Set();
|
|
@@ -55,7 +55,9 @@ export function subscribeTargetDispatches(fn) {
|
|
|
55
55
|
export function useDispatchTarget() {
|
|
56
56
|
const nav = useNav();
|
|
57
57
|
return useCallback((target) => {
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
requestLeave(() => {
|
|
59
|
+
nav.setSearchParams(serializeTarget(target, nav.searchParams));
|
|
60
|
+
dispatchTarget(target);
|
|
61
|
+
});
|
|
60
62
|
}, [nav]);
|
|
61
63
|
}
|
|
@@ -901,12 +901,15 @@ function useRowActionTrigger(row) {
|
|
|
901
901
|
catch (e) {
|
|
902
902
|
// Surfacing statt schlucken: ein verschluckter Write-Fehler sah für
|
|
903
903
|
// den User wie "nichts passiert" aus (Prod-Bug 2026-06-07).
|
|
904
|
-
const
|
|
904
|
+
const dispatcherError = e instanceof WriteFailedError ? e.dispatcherError : undefined;
|
|
905
|
+
const docsUrl = dispatcherError?.docsUrl;
|
|
906
|
+
const requestId = dispatcherError?.requestId;
|
|
905
907
|
toast({
|
|
906
908
|
title: t("kumiko.rowAction.failed"),
|
|
907
909
|
description: e instanceof Error ? e.message : String(e),
|
|
908
910
|
variant: "bad",
|
|
909
911
|
...(docsUrl !== undefined && { docsUrl }),
|
|
912
|
+
...(requestId !== undefined && { requestId }),
|
|
910
913
|
});
|
|
911
914
|
}
|
|
912
915
|
finally {
|
|
@@ -12,6 +12,7 @@ import { X } from "lucide-react";
|
|
|
12
12
|
import { createContext, useCallback, useContext, useId, useMemo, useRef, useState, } from "react";
|
|
13
13
|
import { useTranslation } from "../../index.js";
|
|
14
14
|
import { cn } from "../lib/cn.js";
|
|
15
|
+
import { CopyButton } from "./share-actions.js";
|
|
15
16
|
const ToastContext = createContext(null);
|
|
16
17
|
export function useToast() {
|
|
17
18
|
const ctx = useContext(ToastContext);
|
|
@@ -52,6 +53,11 @@ const TONE_CLASS = {
|
|
|
52
53
|
critical: "border-status-critical/40 bg-status-critical/15 text-status-critical",
|
|
53
54
|
muted: "border bg-background text-foreground",
|
|
54
55
|
};
|
|
56
|
+
function ToastErrorReference({ requestId }) {
|
|
57
|
+
const t = useTranslation();
|
|
58
|
+
const reference = t("kumiko.error.reference", { id: requestId });
|
|
59
|
+
return (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs", "data-testid": "toast-error-reference", children: [_jsx("span", { className: "min-w-0 truncate whitespace-nowrap select-all opacity-90", title: reference, children: reference }), _jsx(CopyButton, { text: requestId, label: t("kumiko.error.copyReference"), copiedLabel: t("kumiko.error.referenceCopied"), variant: "secondary", className: "min-h-0 h-7 shrink-0", testId: "toast-error-reference-copy" })] }));
|
|
60
|
+
}
|
|
55
61
|
function ToastItem({ entry, onClose, }) {
|
|
56
62
|
const t = useTranslation();
|
|
57
63
|
const learnMore = entry.docsLinkLabel ?? t("kumiko.toast.learn-more");
|
|
@@ -59,5 +65,5 @@ function ToastItem({ entry, onClose, }) {
|
|
|
59
65
|
return (_jsxs(Primitive.Root, { className: cn(rootClass, variantClass), onOpenChange: (open) => {
|
|
60
66
|
if (!open)
|
|
61
67
|
onClose();
|
|
62
|
-
}, children: [_jsxs("div", { className: "grid gap-1", children: [_jsx(Primitive.Title, { className: "text-sm font-semibold", children: entry.title }), entry.description !== undefined && (_jsx(Primitive.Description, { className: "text-sm opacity-90", children: entry.description })), entry.docsUrl !== undefined && (_jsx(Primitive.Action, { altText: learnMore, asChild: true, children: _jsxs("a", { href: entry.docsUrl, target: "_blank", rel: "noopener noreferrer", className: cn("text-xs underline opacity-90 hover:opacity-100", "focus:outline-none focus:ring-1 focus:ring-current rounded"), children: [learnMore, " \u2192"] }) }))] }), _jsx(Primitive.Close, { className: cn("absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity", "hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1", "group-hover:opacity-100"), "aria-label": t("kumiko.dialog.close"), children: _jsx(X, { className: "h-4 w-4" }) })] }));
|
|
68
|
+
}, children: [_jsxs("div", { className: "grid min-w-0 gap-1", children: [_jsx(Primitive.Title, { className: "text-sm font-semibold", children: entry.title }), entry.description !== undefined && (_jsx(Primitive.Description, { className: "text-sm opacity-90", children: entry.description })), entry.requestId !== undefined && _jsx(ToastErrorReference, { requestId: entry.requestId }), entry.docsUrl !== undefined && (_jsx(Primitive.Action, { altText: learnMore, asChild: true, children: _jsxs("a", { href: entry.docsUrl, target: "_blank", rel: "noopener noreferrer", className: cn("text-xs underline opacity-90 hover:opacity-100", "focus:outline-none focus:ring-1 focus:ring-current rounded"), children: [learnMore, " \u2192"] }) }))] }), _jsx(Primitive.Close, { className: cn("absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity", "hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1", "group-hover:opacity-100"), "aria-label": t("kumiko.dialog.close"), children: _jsx(X, { className: "h-4 w-4" }) })] }));
|
|
63
69
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.383.1",
|
|
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": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
52
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
51
|
+
"@cosmicdrift/kumiko-framework": "0.383.1",
|
|
52
|
+
"@cosmicdrift/kumiko-types": "0.383.1",
|
|
53
53
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
54
54
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
55
55
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -92,8 +92,8 @@
|
|
|
92
92
|
}
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
95
|
+
"@cosmicdrift/kumiko-locale-de": "0.383.1",
|
|
96
|
+
"@cosmicdrift/kumiko-testing": "0.383.1",
|
|
97
97
|
"@tailwindcss/cli": "^4.3.0",
|
|
98
98
|
"@testing-library/react": "^16.3.2",
|
|
99
99
|
"@testing-library/user-event": "^14.6.1",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,35 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "projectionDetail metrics render their own label",
|
|
6
|
+
"detail": "The metrics band reads the label from each metric entry instead of falling back to `fieldLabels` or the column name."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.383.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Error reference with copy button in toasts, banners and fallbacks",
|
|
12
|
+
"detail": "A server error that carries a request id now shows \"Error ID: <id>\" with a copy button in toasts, action error banners, the write-form error section, query error banners and list empty states. The root and screen error fallbacks show a per-error id the same way; the root fallback is styled with inline styles so it stays readable without CSS. New exports ErrorReference, ErrorNotice and errorNoticeOf; New keys kumiko.error.reference, kumiko.error.copyReference and kumiko.error.referenceCopied in all locale packages."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.383.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "HeaderActionsBar onError receives an ErrorNotice instead of a string",
|
|
18
|
+
"detail": "The onError prop of HeaderActionsBar is now (notice: ErrorNotice | null) => void, where ErrorNotice is { message, requestId? }, so the error reference can be shown next to the message.",
|
|
19
|
+
"migration": "change the handler to read notice.message (and notice.requestId); a handler that stores the string only needs `notice?.message ?? null`"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.383.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "createKumikoApp reports browser errors to POST /api/client-errors",
|
|
25
|
+
"detail": "New option reportClientErrors (default true) sends error-boundary errors (kind render) and window error and unhandledrejection events (kind unhandled) to /api/client-errors with the same errorId the fallback shows, at most 5 reports per page load, identical messages once, path without query and hash, CSRF header when a token is present. A custom onError runs in addition and receives { errorId, screen? } as third argument. Set reportClientErrors: false to opt out."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.383.0",
|
|
29
|
+
"type": "fix",
|
|
30
|
+
"title": "Global navigation with search params asks before dropping unsaved input",
|
|
31
|
+
"detail": "navigateWithReturnTo, navigateToReturn, the metric tab navigation, closing the editor panel and the target resolver now go through the leave guard, and a requestLeave nested in a confirmed leave no longer asks twice. List filters and sorting stay unguarded because they do not unmount a form. The mail designer registers its draft as unsaved input, so link and browser navigation ask before discarding it."
|
|
32
|
+
},
|
|
2
33
|
{
|
|
3
34
|
"version": "0.381.0",
|
|
4
35
|
"type": "improvement",
|