@cosmicdrift/kumiko-renderer 0.373.0 → 0.374.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.
@@ -0,0 +1,51 @@
1
+ import type { ActionFormLoad } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { DispatcherError, Translate } from "@cosmicdrift/kumiko-headless";
3
+ import { type ReactNode } from "react";
4
+ /** Record id of a `load` form: the route path id, else the drawer prefill `id`
5
+ * (the clicked row), else the URL `id` param. Only the id is read from these
6
+ * sources, never another value. */
7
+ export declare function resolveActionFormRecordId(sources: {
8
+ readonly routeEntityId: string | undefined;
9
+ readonly drawerOverrides: Readonly<Record<string, unknown>> | undefined;
10
+ readonly searchParams: Readonly<Record<string, string>>;
11
+ }): string | undefined;
12
+ export type ActionFormLoadState = {
13
+ readonly status: "missing-id";
14
+ } | {
15
+ readonly status: "loading";
16
+ } | {
17
+ readonly status: "error";
18
+ readonly error: DispatcherError;
19
+ } | {
20
+ readonly status: "missing-record";
21
+ } | {
22
+ readonly status: "ready";
23
+ readonly values: Readonly<Record<string, unknown>>;
24
+ };
25
+ /** A sensitive, writeOnly or password field never takes a value from a loaded
26
+ * record: the form must not echo a secret back into an input. */
27
+ export declare function isSecretFormField(field: unknown): boolean;
28
+ /** Loads the initial values of an actionForm declaring `load`; undefined for a
29
+ * form without `load`. */
30
+ export declare function useActionFormLoad(load: ActionFormLoad | undefined, recordId: string | undefined, fieldNames: readonly string[]): ActionFormLoadState | undefined;
31
+ /** Banner for every `load` state that is not "ready". */
32
+ export declare function ActionFormLoadStatus({ state, screenId, translate, }: {
33
+ readonly state: Exclude<ActionFormLoadState, {
34
+ readonly status: "ready";
35
+ }>;
36
+ readonly screenId: string;
37
+ readonly translate: Translate;
38
+ }): ReactNode;
39
+ export type ResolvedSuccessLink = {
40
+ readonly key: string;
41
+ readonly label: string;
42
+ readonly href: string;
43
+ readonly onPress: () => void;
44
+ };
45
+ export type ActionFormSuccessState = {
46
+ readonly text: string;
47
+ readonly links: readonly ResolvedSuccessLink[];
48
+ };
49
+ export declare function ActionFormSuccessBanner({ success, }: {
50
+ readonly success: ActionFormSuccessState;
51
+ }): ReactNode;
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
3
+ import { useMemo } from "react";
4
+ import { useQuery } from "../hooks/use-query.js";
5
+ import { usePrimitives } from "../primitives.js";
6
+ import { QueryLoadingBanner } from "./query-state-banners.js";
7
+ import { dispatcherErrorText } from "./write-failed-error.js";
8
+ const DEFAULT_LOAD_ID_PARAM = "id";
9
+ function nonEmptyId(value) {
10
+ if (typeof value === "string" && value !== "")
11
+ return value;
12
+ return typeof value === "number" ? String(value) : undefined;
13
+ }
14
+ /** Record id of a `load` form: the route path id, else the drawer prefill `id`
15
+ * (the clicked row), else the URL `id` param. Only the id is read from these
16
+ * sources, never another value. */
17
+ export function resolveActionFormRecordId(sources) {
18
+ return (nonEmptyId(sources.routeEntityId) ??
19
+ nonEmptyId(sources.drawerOverrides?.[DEFAULT_LOAD_ID_PARAM]) ??
20
+ nonEmptyId(sources.searchParams[DEFAULT_LOAD_ID_PARAM]));
21
+ }
22
+ /** A sensitive, writeOnly or password field never takes a value from a loaded
23
+ * record: the form must not echo a secret back into an input. */
24
+ export function isSecretFormField(field) {
25
+ return (propertyOf(field, "sensitive") === true ||
26
+ propertyOf(field, "writeOnly") === true ||
27
+ propertyOf(field, "format") === "password");
28
+ }
29
+ function pickDeclaredFields(record, fieldNames) {
30
+ if (typeof record !== "object" || record === null)
31
+ return {};
32
+ const picked = {};
33
+ for (const name of fieldNames) {
34
+ const value = Object.hasOwn(record, name) ? Reflect.get(record, name) : undefined;
35
+ if (value !== undefined && value !== null)
36
+ picked[name] = value;
37
+ }
38
+ return picked;
39
+ }
40
+ /** Loads the initial values of an actionForm declaring `load`; undefined for a
41
+ * form without `load`. */
42
+ export function useActionFormLoad(load, recordId, fieldNames) {
43
+ const query = useQuery(load?.query ?? "", { [load?.idParam ?? DEFAULT_LOAD_ID_PARAM]: recordId }, { enabled: load !== undefined && recordId !== undefined });
44
+ const values = useMemo(() => pickDeclaredFields(query.data, fieldNames), [query.data, fieldNames]);
45
+ if (load === undefined)
46
+ return undefined;
47
+ if (recordId === undefined)
48
+ return { status: "missing-id" };
49
+ if (query.error !== null)
50
+ return { status: "error", error: query.error };
51
+ if (query.loading)
52
+ return { status: "loading" };
53
+ if (query.data === null)
54
+ return { status: "missing-record" };
55
+ return { status: "ready", values };
56
+ }
57
+ /** Banner for every `load` state that is not "ready". */
58
+ export function ActionFormLoadStatus({ state, screenId, translate, }) {
59
+ const { Banner, Text } = usePrimitives();
60
+ switch (state.status) {
61
+ case "loading":
62
+ return _jsx(QueryLoadingBanner, {});
63
+ case "error":
64
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(state.error, translate) }));
65
+ case "missing-record":
66
+ return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-record-missing", children: translate("kumiko.record.not-found.title") }));
67
+ case "missing-id":
68
+ return (_jsxs(Banner, { padded: true, variant: "error", testId: "kumiko-screen-action-form-missing-id", children: ["Screen ", _jsx(Text, { variant: "code", children: screenId }), " (actionForm with load) needs a record id in the path or the clicked row."] }));
69
+ }
70
+ }
71
+ export function ActionFormSuccessBanner({ success, }) {
72
+ const { Banner, Link } = usePrimitives();
73
+ return (_jsxs(Banner, { variant: "info", testId: "action-form-success", children: [success.text, success.links.map((link) => (_jsx(Link, { href: link.href, onPress: link.onPress, className: "ml-2 underline", testId: `action-form-success-link-${link.key}`, children: link.label }, link.key)))] }));
74
+ }
@@ -0,0 +1,46 @@
1
+ import type { RowActionClientAction } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
3
+ import { type ReactNode } from "react";
4
+ export type ClientActionOutcome = {
5
+ readonly ok: true;
6
+ }
7
+ /** `message` is an i18n key; a literal is shown as is. */
8
+ | {
9
+ readonly ok: false;
10
+ readonly message: string;
11
+ };
12
+ export type ClientActionHandler = (payload: Readonly<Record<string, unknown>>) => Promise<ClientActionOutcome>;
13
+ export type ClientActionsMap = Readonly<Record<string, ClientActionHandler>>;
14
+ export type ActionNotice = {
15
+ readonly tone: "success" | "error";
16
+ readonly text: string;
17
+ };
18
+ /** Shows the outcome of a client action. The platform layer provides it
19
+ * (renderer-web: a toast); without one a failure is thrown to the action
20
+ * host's own error surface and a success stays silent. */
21
+ export type ActionNotifier = (notice: ActionNotice) => void;
22
+ export type ClientActionRuntime = {
23
+ readonly handlers: ClientActionsMap | undefined;
24
+ readonly notify: ActionNotifier | undefined;
25
+ };
26
+ export declare function ClientActionsProvider({ children, value, }: {
27
+ readonly children: ReactNode;
28
+ readonly value: ClientActionsMap;
29
+ }): ReactNode;
30
+ export declare function ActionNoticeProvider({ children, notify, }: {
31
+ readonly children: ReactNode;
32
+ readonly notify: ActionNotifier;
33
+ }): ReactNode;
34
+ export declare function useClientActionHandler(name: string): ClientActionHandler | undefined;
35
+ /** Handlers plus notifier, as the action builders take them. */
36
+ export declare function useClientActionRuntime(): ClientActionRuntime;
37
+ /** Runs the handler registered for `action.action` and reports the outcome.
38
+ * Resolves true when the handler returned `{ ok: true }` (the caller then
39
+ * refetches). A failure goes to the notifier; without one it is thrown so the
40
+ * host's own error surface (row toast, header banner) shows it. */
41
+ export declare function runClientAction(options: {
42
+ readonly action: RowActionClientAction;
43
+ readonly payload: Readonly<Record<string, unknown>>;
44
+ readonly runtime: ClientActionRuntime | undefined;
45
+ readonly translate: Translate;
46
+ }): Promise<boolean>;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo } from "react";
3
+ const ClientActionsContext = createContext(undefined);
4
+ const ActionNoticeContext = createContext(undefined);
5
+ export function ClientActionsProvider({ children, value, }) {
6
+ return _jsx(ClientActionsContext.Provider, { value: value, children: children });
7
+ }
8
+ export function ActionNoticeProvider({ children, notify, }) {
9
+ return _jsx(ActionNoticeContext.Provider, { value: notify, children: children });
10
+ }
11
+ export function useClientActionHandler(name) {
12
+ return findClientActionHandler(useContext(ClientActionsContext), name);
13
+ }
14
+ /** Handlers plus notifier, as the action builders take them. */
15
+ export function useClientActionRuntime() {
16
+ const handlers = useContext(ClientActionsContext);
17
+ const notify = useContext(ActionNoticeContext);
18
+ return useMemo(() => ({ handlers, notify }), [handlers, notify]);
19
+ }
20
+ function findClientActionHandler(handlers, name) {
21
+ return handlers !== undefined && Object.hasOwn(handlers, name) ? handlers[name] : undefined;
22
+ }
23
+ function logClientActionProblem(name, problem, cause) {
24
+ // biome-ignore lint/suspicious/noConsole: the client has no logger; the toast text is generic, so the action name and cause must reach the DevTools console
25
+ console.error(`[kumiko] clientAction "${name}" ${problem}`, ...(cause === undefined ? [] : [cause]));
26
+ }
27
+ /** Runs the handler registered for `action.action` and reports the outcome.
28
+ * Resolves true when the handler returned `{ ok: true }` (the caller then
29
+ * refetches). A failure goes to the notifier; without one it is thrown so the
30
+ * host's own error surface (row toast, header banner) shows it. */
31
+ export async function runClientAction(options) {
32
+ const { action, payload, runtime, translate } = options;
33
+ const notify = runtime?.notify;
34
+ const fail = (text) => {
35
+ if (notify === undefined)
36
+ throw new Error(text);
37
+ notify({ tone: "error", text });
38
+ return false;
39
+ };
40
+ const handler = findClientActionHandler(runtime?.handlers, action.action);
41
+ if (handler === undefined) {
42
+ logClientActionProblem(action.action, "is not registered by any client feature");
43
+ return fail(translate("kumiko.clientAction.unregistered"));
44
+ }
45
+ let outcome;
46
+ try {
47
+ outcome = await handler(payload);
48
+ }
49
+ catch (error) {
50
+ logClientActionProblem(action.action, "threw", error);
51
+ return fail(translate("kumiko.clientAction.failed"));
52
+ }
53
+ if (!outcome.ok)
54
+ return fail(translate(outcome.message));
55
+ notify?.({
56
+ tone: "success",
57
+ text: translate(action.successMessage ?? "kumiko.clientAction.success"),
58
+ });
59
+ return true;
60
+ }
@@ -5,6 +5,7 @@ import { RenderEditActionButton } from "../components/render-edit-action-button.
5
5
  import { useOptionalDispatcher } from "../context/dispatcher-context.js";
6
6
  import { useTranslation } from "../i18n.js";
7
7
  import { usePrimitives } from "../primitives.js";
8
+ import { useClientActionRuntime } from "./client-actions.js";
8
9
  import { useNav } from "./nav.js";
9
10
  import { useReturnHost } from "./return-to.js";
10
11
  import { buildRecordActions } from "./row-actions.js";
@@ -18,6 +19,7 @@ export function EntityListExpandedRow({ section, row, featureName, translate, ex
18
19
  const nav = useNav();
19
20
  const host = useReturnHost();
20
21
  const dispatcher = useOptionalDispatcher();
22
+ const clientActions = useClientActionRuntime();
21
23
  const [actionError, setActionError] = useState(null);
22
24
  const onTitleActionWritten = useCallback(async () => {
23
25
  onSubListInvalidated();
@@ -33,6 +35,7 @@ export function EntityListExpandedRow({ section, row, featureName, translate, ex
33
35
  dispatcher,
34
36
  openDrawer,
35
37
  onWriteSuccess: onTitleActionWritten,
38
+ clientActions,
36
39
  })
37
40
  : undefined, [
38
41
  section.actions,
@@ -43,6 +46,7 @@ export function EntityListExpandedRow({ section, row, featureName, translate, ex
43
46
  dispatcher,
44
47
  openDrawer,
45
48
  onTitleActionWritten,
49
+ clientActions,
46
50
  ]);
47
51
  const titleActionButtons = titleActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id)));
48
52
  return (_jsxs(_Fragment, { children: [actionError !== null && (_jsx(Banner, { variant: "error", testId: `row-${row.id}-expansion-action-error`, children: actionError })), _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)] }));
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from "react";
2
+ import type { RenderEditAction } from "../components/render-edit-types.ts";
3
+ import type { usePrimitives } from "../primitives.tsx";
4
+ export declare function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary, compact, preferredPrimaryId, PageHeader, overflowTestId, }: {
5
+ /** `primaryAction` of the screen: wins over the edit/primary-style precedence. */
6
+ readonly preferredPrimaryId?: string;
7
+ /** Header slot layout: only the primary action stays a button, the rest goes into the menu. */
8
+ readonly collapseAfterPrimary?: boolean;
9
+ /** Phone-width shell header: an icon-only primary button, the rest in the shell's overflow menu. */
10
+ readonly compact?: boolean;
11
+ readonly PageHeader?: ReturnType<typeof usePrimitives>["PageHeader"];
12
+ readonly actions: readonly RenderEditAction[];
13
+ readonly Button: ReturnType<typeof usePrimitives>["Button"];
14
+ readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
15
+ readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
16
+ readonly onError: (text: string | null) => void;
17
+ /** Test id of the overflow menu; projectionDetail's by default. */
18
+ readonly overflowTestId?: string;
19
+ }): ReactNode;
@@ -0,0 +1,30 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { RenderEditActionButton } from "../components/render-edit-action-button.js";
3
+ import { useActionMenuItems } from "../components/use-action-menu-items.js";
4
+ import { useTranslation } from "../i18n.js";
5
+ import { compactHeaderPrimary, orderPlainHeaderActions, primaryHeaderAction, } from "./header-actions.js";
6
+ // Header actions bar (A7): <=2 actions render as plain buttons; >2 with an
7
+ // `ActionOverflowMenu` primitive collapse to the primary button plus a menu
8
+ // for the rest — same rule as RowActionsCell for table rows. Owns confirm
9
+ // state for menu items: RenderEditActionButton already owns a per-button
10
+ // confirm dialog for the always-visible primary action, but a menu item has
11
+ // no button of its own to carry one.
12
+ export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, preferredPrimaryId, PageHeader, overflowTestId = "kumiko-screen-projection-detail-actions-overflow", }) {
13
+ const t = useTranslation();
14
+ const { toMenuItem, confirmDialog: pendingActionDialog } = useActionMenuItems({
15
+ Dialog,
16
+ onError,
17
+ });
18
+ if (compact && PageHeader !== undefined) {
19
+ const compactPrimary = compactHeaderPrimary(actions, preferredPrimaryId);
20
+ const overflowActions = actions.filter((a) => a !== compactPrimary?.action);
21
+ return (_jsxs(_Fragment, { children: [compactPrimary !== undefined && (_jsx(RenderEditActionButton, { action: compactPrimary.action, Button: Button, Dialog: Dialog, iconOnly: compactPrimary.iconOnly, compactLabel: true, onError: onError }, compactPrimary.action.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
22
+ }
23
+ const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
24
+ if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
25
+ return (_jsx(_Fragment, { children: orderPlainHeaderActions(actions, preferredPrimaryId).map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
26
+ }
27
+ const primary = primaryHeaderAction(actions, preferredPrimaryId);
28
+ const rest = actions.filter((a) => a.id !== primary?.id);
29
+ return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: overflowTestId, items: rest.map(toMenuItem) }), pendingActionDialog] }));
30
+ }