@cosmicdrift/kumiko-renderer 0.307.0 → 0.309.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/package.json +5 -5
- package/src/app/form-schema.ts +1 -1
- package/src/app/kumiko-screen.tsx +91 -259
- package/src/app/row-actions.ts +215 -56
- package/src/changes.json +7 -0
- package/src/components/__tests__/related-list-section-empty-state.test.tsx +143 -0
- package/src/components/__tests__/related-list-section.test.tsx +2 -2
- package/src/components/__tests__/render-field-number-grouping.test.tsx +76 -0
- package/src/components/__tests__/write-form-section.test.tsx +54 -1
- package/src/components/related-list-section.tsx +94 -15
- package/src/components/render-edit-types.ts +13 -2
- package/src/components/render-edit.tsx +147 -36
- package/src/components/render-field.tsx +3 -1
- package/src/components/render-list.tsx +6 -4
- package/src/components/write-form-section.tsx +37 -4
- package/src/primitives.tsx +11 -3
package/src/app/row-actions.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
EntityEditScreenDefinition,
|
|
3
|
-
IconKey,
|
|
4
3
|
RelatedListToolbarAction,
|
|
5
4
|
RowAction,
|
|
6
5
|
RowActionDrawer,
|
|
@@ -11,6 +10,9 @@ import type {
|
|
|
11
10
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
12
11
|
import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
13
12
|
import type { Dispatcher, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
|
|
13
|
+
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
14
|
+
import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
15
|
+
import type { RenderEditAction } from "../components/render-edit-types";
|
|
14
16
|
import type { ToolbarActionButton } from "../components/render-list";
|
|
15
17
|
import type { DataTableRowAction } from "../primitives";
|
|
16
18
|
import type { NavApi, ScreenTarget } from "./nav";
|
|
@@ -48,61 +50,6 @@ export function isWriteHandlerRowAction(action: RowAction): action is RowActionW
|
|
|
48
50
|
return action.kind === "writeHandler" || action.kind === undefined;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
// Part B (fw-ui-defaults): id-derived default icon for actions that never
|
|
52
|
-
// declared one — a screen author still gets a recognizable glyph instead of
|
|
53
|
-
// a bare label. Checked against the actually registered IconKey vocabulary
|
|
54
|
-
// (nav-icon.ts) — no entry for verbs without a matching icon (e.g. "start",
|
|
55
|
-
// "pause").
|
|
56
|
-
const ACTION_ICON_BY_ID: Readonly<Partial<Record<string, IconKey>>> = {
|
|
57
|
-
delete: "trash",
|
|
58
|
-
edit: "pencil",
|
|
59
|
-
create: "plus",
|
|
60
|
-
new: "plus",
|
|
61
|
-
add: "plus",
|
|
62
|
-
view: "eye",
|
|
63
|
-
open: "eye",
|
|
64
|
-
cancel: "x",
|
|
65
|
-
reject: "x",
|
|
66
|
-
complete: "check",
|
|
67
|
-
resolve: "check",
|
|
68
|
-
approve: "check",
|
|
69
|
-
archive: "archive",
|
|
70
|
-
publish: "upload",
|
|
71
|
-
duplicate: "copy",
|
|
72
|
-
copy: "copy",
|
|
73
|
-
download: "download",
|
|
74
|
-
refresh: "refresh",
|
|
75
|
-
retry: "refresh",
|
|
76
|
-
settings: "settings",
|
|
77
|
-
share: "share",
|
|
78
|
-
send: "send",
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Ids are kebab-case (RowAction.id doc): aggregate-object-verb
|
|
82
|
-
// ("order-ship") or verb-prefix ("add-item").
|
|
83
|
-
function kebabLastSegment(id: string): string {
|
|
84
|
-
const idx = id.lastIndexOf("-");
|
|
85
|
-
return idx === -1 ? id : id.slice(idx + 1);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function kebabFirstSegment(id: string): string {
|
|
89
|
-
const idx = id.indexOf("-");
|
|
90
|
-
return idx === -1 ? id : id.slice(0, idx);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Resolution order: author-declared `icon` wins, then the id-derived
|
|
94
|
-
// default (full id, then its last kebab segment, then its first kebab
|
|
95
|
-
// segment). `declared` is `undefined` for ToolbarAction, which has no
|
|
96
|
-
// author-facing icon field.
|
|
97
|
-
export function resolveActionIcon(id: string, declared?: IconKey): IconKey | undefined {
|
|
98
|
-
if (declared !== undefined) return declared;
|
|
99
|
-
return (
|
|
100
|
-
ACTION_ICON_BY_ID[id] ??
|
|
101
|
-
ACTION_ICON_BY_ID[kebabLastSegment(id)] ??
|
|
102
|
-
ACTION_ICON_BY_ID[kebabFirstSegment(id)]
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
53
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
107
54
|
if (typeof value !== "object" || value === null) return false;
|
|
108
55
|
const proto: object | null = Object.getPrototypeOf(value);
|
|
@@ -473,3 +420,215 @@ export function buildProjectionToolbarActions(options: {
|
|
|
473
420
|
}
|
|
474
421
|
return out.length > 0 ? out : undefined;
|
|
475
422
|
}
|
|
423
|
+
|
|
424
|
+
function buildNavigateRecordAction(
|
|
425
|
+
action: RowActionNavigate,
|
|
426
|
+
options: {
|
|
427
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
428
|
+
readonly translate: Translate;
|
|
429
|
+
readonly nav: NavApi;
|
|
430
|
+
readonly host: ReturnHost | undefined;
|
|
431
|
+
readonly actionIcon: IconKey | undefined;
|
|
432
|
+
readonly defaultScreenTargetEntityId: string | undefined;
|
|
433
|
+
readonly sameEntityScreenId: ((targetScreen: string) => string | undefined) | undefined;
|
|
434
|
+
},
|
|
435
|
+
): RenderEditAction | undefined {
|
|
436
|
+
const {
|
|
437
|
+
record,
|
|
438
|
+
translate,
|
|
439
|
+
nav,
|
|
440
|
+
host,
|
|
441
|
+
actionIcon,
|
|
442
|
+
defaultScreenTargetEntityId,
|
|
443
|
+
sameEntityScreenId,
|
|
444
|
+
} = options;
|
|
445
|
+
const runParams = (): void => {
|
|
446
|
+
const params =
|
|
447
|
+
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
448
|
+
if (params !== undefined) nav.setSearchParams(stringifyNavParams(params));
|
|
449
|
+
};
|
|
450
|
+
if (action.entity !== undefined) {
|
|
451
|
+
const targetEntity = action.entity;
|
|
452
|
+
const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
|
|
453
|
+
return {
|
|
454
|
+
id: action.id,
|
|
455
|
+
label: translate(action.label),
|
|
456
|
+
...(action.style !== undefined && { style: action.style }),
|
|
457
|
+
confirmRequired: false,
|
|
458
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
459
|
+
onPress: () => {
|
|
460
|
+
// No entityId on record (id === "") → nothing to navigate to.
|
|
461
|
+
if (id !== "") {
|
|
462
|
+
nav.navigate({ entity: targetEntity, id });
|
|
463
|
+
runParams();
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (action.screen !== undefined) {
|
|
469
|
+
const explicit =
|
|
470
|
+
action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
|
|
471
|
+
const fallback = sameEntityScreenId?.(action.screen) ?? defaultScreenTargetEntityId;
|
|
472
|
+
const navEntityId = explicit ?? fallback;
|
|
473
|
+
const targetScreen = action.screen;
|
|
474
|
+
return {
|
|
475
|
+
id: action.id,
|
|
476
|
+
label: translate(action.label),
|
|
477
|
+
...(action.style !== undefined && { style: action.style }),
|
|
478
|
+
confirmRequired: false,
|
|
479
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
480
|
+
onPress: () => {
|
|
481
|
+
const target: ScreenTarget = {
|
|
482
|
+
screenId: targetScreen,
|
|
483
|
+
...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
|
|
484
|
+
};
|
|
485
|
+
const params =
|
|
486
|
+
action.params !== undefined
|
|
487
|
+
? stringifyNavParams(evalRowExtractor(action.params, record))
|
|
488
|
+
: undefined;
|
|
489
|
+
navigateWithReturnTo(nav, target, host, params);
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function buildDrawerRecordAction(
|
|
497
|
+
action: RowActionDrawer,
|
|
498
|
+
options: {
|
|
499
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
500
|
+
readonly translate: Translate;
|
|
501
|
+
readonly actionIcon: IconKey | undefined;
|
|
502
|
+
readonly openDrawer: (
|
|
503
|
+
action: RowActionDrawer,
|
|
504
|
+
initialValues: Readonly<Record<string, unknown>> | undefined,
|
|
505
|
+
) => void;
|
|
506
|
+
},
|
|
507
|
+
): RenderEditAction {
|
|
508
|
+
const { record, translate, actionIcon, openDrawer } = options;
|
|
509
|
+
return {
|
|
510
|
+
id: action.id,
|
|
511
|
+
label: translate(action.label),
|
|
512
|
+
...(action.style !== undefined && { style: action.style }),
|
|
513
|
+
confirmRequired: false,
|
|
514
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
515
|
+
onPress: () => {
|
|
516
|
+
const initialValues =
|
|
517
|
+
action.params !== undefined ? evalRowExtractor(action.params, record) : undefined;
|
|
518
|
+
openDrawer(action, initialValues);
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function buildWriteHandlerRecordAction(
|
|
524
|
+
action: RowActionWriteHandler,
|
|
525
|
+
options: {
|
|
526
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
527
|
+
readonly translate: Translate;
|
|
528
|
+
readonly actionIcon: IconKey | undefined;
|
|
529
|
+
readonly dispatcher: Dispatcher;
|
|
530
|
+
readonly onWriteSuccess: () => void | Promise<void>;
|
|
531
|
+
},
|
|
532
|
+
): RenderEditAction {
|
|
533
|
+
const { record, translate, actionIcon, dispatcher, onWriteSuccess } = options;
|
|
534
|
+
return {
|
|
535
|
+
id: action.id,
|
|
536
|
+
label: translate(action.label),
|
|
537
|
+
...(action.style !== undefined && { style: action.style }),
|
|
538
|
+
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
539
|
+
...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
|
|
540
|
+
...(action.confirmLabel !== undefined && {
|
|
541
|
+
confirmLabel: translate(action.confirmLabel),
|
|
542
|
+
}),
|
|
543
|
+
onPress: async () => {
|
|
544
|
+
const payload =
|
|
545
|
+
action.payload !== undefined
|
|
546
|
+
? evalRowExtractor(action.payload, record)
|
|
547
|
+
: { id: record["id"] };
|
|
548
|
+
const result = await dispatcher.write(action.handler, payload);
|
|
549
|
+
if (!result.isSuccess) {
|
|
550
|
+
throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
|
|
551
|
+
}
|
|
552
|
+
await onWriteSuccess();
|
|
553
|
+
},
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Shared builder for a RowAction[] evaluated against one "record"
|
|
558
|
+
// context (not a specific list row) — a head card's `screen.actions`, an
|
|
559
|
+
// entityEdit's own `screen.actions`, and any section-level
|
|
560
|
+
// `actions`/`emptyState.action` are all this same shape: a RowAction
|
|
561
|
+
// resolved against the record currently being viewed/edited, independent of
|
|
562
|
+
// any table row.
|
|
563
|
+
export function buildRecordActions(options: {
|
|
564
|
+
readonly actions: readonly RowAction[];
|
|
565
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
566
|
+
readonly translate: Translate;
|
|
567
|
+
readonly nav: NavApi;
|
|
568
|
+
readonly host: ReturnHost | undefined;
|
|
569
|
+
readonly dispatcher: Dispatcher | undefined;
|
|
570
|
+
readonly openDrawer: (
|
|
571
|
+
action: RowActionDrawer,
|
|
572
|
+
initialValues: Readonly<Record<string, unknown>> | undefined,
|
|
573
|
+
) => void;
|
|
574
|
+
/** Called after a writeHandler action succeeds (refetch/reload). */
|
|
575
|
+
readonly onWriteSuccess: () => void | Promise<void>;
|
|
576
|
+
/** Screen-target navigate default entityId when the action declares no
|
|
577
|
+
* explicit `entityId` field-source. A caller editing a fixed entity can
|
|
578
|
+
* pass its own `entityId` here directly (the record it already is). A
|
|
579
|
+
* caller whose shown record may differ from the navigate target's entity
|
|
580
|
+
* resolves this per-target via `sameEntityScreenId` instead. */
|
|
581
|
+
readonly defaultScreenTargetEntityId?: string;
|
|
582
|
+
/** Given a navigate action's target screen id, returns the record id to
|
|
583
|
+
* default to IF that screen edits the same entity the current record
|
|
584
|
+
* belongs to (cross-feature lookup) — undefined otherwise. Omitted by
|
|
585
|
+
* callers with no such cross-screen lookup. */
|
|
586
|
+
readonly sameEntityScreenId?: (targetScreen: string) => string | undefined;
|
|
587
|
+
}): readonly RenderEditAction[] | undefined {
|
|
588
|
+
const {
|
|
589
|
+
actions,
|
|
590
|
+
record,
|
|
591
|
+
translate,
|
|
592
|
+
nav,
|
|
593
|
+
host,
|
|
594
|
+
dispatcher,
|
|
595
|
+
openDrawer,
|
|
596
|
+
onWriteSuccess,
|
|
597
|
+
defaultScreenTargetEntityId,
|
|
598
|
+
sameEntityScreenId,
|
|
599
|
+
} = options;
|
|
600
|
+
const out: RenderEditAction[] = [];
|
|
601
|
+
for (const action of actions) {
|
|
602
|
+
if (action.visible !== undefined && !evalFieldCondition(action.visible, record)) continue;
|
|
603
|
+
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
604
|
+
if (action.kind === "navigate") {
|
|
605
|
+
const built = buildNavigateRecordAction(action, {
|
|
606
|
+
record,
|
|
607
|
+
translate,
|
|
608
|
+
nav,
|
|
609
|
+
host,
|
|
610
|
+
actionIcon,
|
|
611
|
+
defaultScreenTargetEntityId,
|
|
612
|
+
sameEntityScreenId,
|
|
613
|
+
});
|
|
614
|
+
if (built !== undefined) out.push(built);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (action.kind === "drawer") {
|
|
618
|
+
out.push(buildDrawerRecordAction(action, { record, translate, actionIcon, openDrawer }));
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
// writeHandler — skip without a dispatcher instead of crashing.
|
|
622
|
+
if (dispatcher === undefined) continue;
|
|
623
|
+
out.push(
|
|
624
|
+
buildWriteHandlerRecordAction(action, {
|
|
625
|
+
record,
|
|
626
|
+
translate,
|
|
627
|
+
actionIcon,
|
|
628
|
+
dispatcher,
|
|
629
|
+
onWriteSuccess,
|
|
630
|
+
}),
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
return out.length > 0 ? out : undefined;
|
|
634
|
+
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.308.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "projectionDetail/entityEdit-tabs sections render through one Card frame, get section-level actions and a relatedList emptyState (fw#3234)",
|
|
6
|
+
"detail": "Every section kind in a tabs-mode layout (fields, extension, relatedList, writeForm) now renders inside exactly one card — extension and writeForm sections previously fell back to a borderless divider there because Section always flattens when rendered inside RenderEdit's own <Form>, so their content (and, for writeForm, its submit button) had no card chrome. EditFieldsSection, EditExtensionSection, EditRelatedListSection and EditWriteFormSection all gain an optional `actions?: readonly RowAction[]`, rendered as buttons in that section's own title row (never a footer). EditRelatedListSection also gains an optional `emptyState?: { title, description?, action? }`, forwarded to the related list's DataTable when it has zero rows. RenderEdit gains a `buildSectionActions` prop the caller uses to resolve a section's RowAction[] against the record being viewed/edited — kumiko-screen.tsx wires this via a new shared `buildRecordActions` helper (row-actions.ts), replacing what used to be two separately maintained header-actions builders.",
|
|
7
|
+
"migration": "Additive: existing screens are unaffected. A projectionDetail/entityEdit-tabs screen with an extension or writeForm section previously relying on the flattened (no card) look in tabs mode now sees that section framed like every other section kind."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.306.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// EditRelatedListSection.emptyState (fw#3234) — forwarded to the DataTable's
|
|
2
|
+
// own `emptyState` prop, rendered as title/description plus an optional
|
|
3
|
+
// record-bound action dispatched the same way a toolbarAction is.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
8
|
+
import type { ComponentType, ReactNode } from "react";
|
|
9
|
+
import { NavProvider } from "../../app/nav";
|
|
10
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
11
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
12
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
13
|
+
import {
|
|
14
|
+
type ButtonProps,
|
|
15
|
+
type CorePrimitives,
|
|
16
|
+
type DataTableProps,
|
|
17
|
+
PrimitivesProvider,
|
|
18
|
+
type TextProps,
|
|
19
|
+
} from "../../primitives";
|
|
20
|
+
import { RelatedListSection } from "../related-list-section";
|
|
21
|
+
|
|
22
|
+
const noop = () => {};
|
|
23
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
24
|
+
|
|
25
|
+
const testDataTable: ComponentType<DataTableProps> = ({ rows, emptyState }) =>
|
|
26
|
+
rows.length === 0 && emptyState !== undefined ? <div>{emptyState}</div> : <table />;
|
|
27
|
+
|
|
28
|
+
function testPrimitives(): CorePrimitives {
|
|
29
|
+
return {
|
|
30
|
+
Button: (props: ButtonProps) => (
|
|
31
|
+
<button type="button" data-testid={props.testId} onClick={() => void props.onClick?.()}>
|
|
32
|
+
{props.children}
|
|
33
|
+
</button>
|
|
34
|
+
),
|
|
35
|
+
Banner: passChildren,
|
|
36
|
+
Field: passChildren,
|
|
37
|
+
Input: noop,
|
|
38
|
+
DataTable: testDataTable,
|
|
39
|
+
Form: noop,
|
|
40
|
+
Section: passChildren,
|
|
41
|
+
Card: passChildren,
|
|
42
|
+
Grid: passChildren,
|
|
43
|
+
GridCell: passChildren,
|
|
44
|
+
Text: ({ children }: TextProps) => <span>{children}</span>,
|
|
45
|
+
Heading: noop,
|
|
46
|
+
Dialog: noop,
|
|
47
|
+
Modal: noop,
|
|
48
|
+
Lightbox: noop,
|
|
49
|
+
ConfigSourceBadge: noop,
|
|
50
|
+
ConfigCascadeView: noop,
|
|
51
|
+
Link: noop,
|
|
52
|
+
} as unknown as CorePrimitives;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const baseSection: EditRelatedListSectionViewModel = {
|
|
56
|
+
kind: "relatedList",
|
|
57
|
+
title: "Notifications",
|
|
58
|
+
query: "orders:query:notifications:list",
|
|
59
|
+
columns: [{ field: "name" }],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function renderEmpty(
|
|
63
|
+
section: EditRelatedListSectionViewModel,
|
|
64
|
+
onWrite?: (type: string, payload: unknown) => void,
|
|
65
|
+
) {
|
|
66
|
+
const dispatcher = {
|
|
67
|
+
write: (async (type: string, payload: unknown) => {
|
|
68
|
+
onWrite?.(type, payload);
|
|
69
|
+
return { isSuccess: true, data: null };
|
|
70
|
+
}) as never,
|
|
71
|
+
query: (async () => ({ isSuccess: true, data: { rows: [], nextCursor: null } })) as never,
|
|
72
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as never,
|
|
73
|
+
statusStore: { getState: () => "online", subscribe: () => () => {} } as never,
|
|
74
|
+
async *stream() {},
|
|
75
|
+
pendingWrites: () => [],
|
|
76
|
+
pendingFiles: () => [],
|
|
77
|
+
};
|
|
78
|
+
return render(
|
|
79
|
+
<LocaleProvider
|
|
80
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
81
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
82
|
+
>
|
|
83
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
84
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
85
|
+
<NavProvider
|
|
86
|
+
value={{
|
|
87
|
+
route: undefined,
|
|
88
|
+
navigate: noop,
|
|
89
|
+
replace: noop,
|
|
90
|
+
hrefFor: () => "#",
|
|
91
|
+
searchParams: {},
|
|
92
|
+
setSearchParams: noop,
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<RelatedListSection
|
|
96
|
+
section={section}
|
|
97
|
+
parentId="order-1"
|
|
98
|
+
record={{ id: "order-1" }}
|
|
99
|
+
featureName="orders"
|
|
100
|
+
/>
|
|
101
|
+
</NavProvider>
|
|
102
|
+
</PrimitivesProvider>
|
|
103
|
+
</DispatcherProvider>
|
|
104
|
+
</LocaleProvider>,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("RelatedListSection — emptyState (fw#3234)", () => {
|
|
109
|
+
test("no emptyState declared: DataTable renders its own fallback, not a custom one", async () => {
|
|
110
|
+
renderEmpty(baseSection);
|
|
111
|
+
await waitFor(() => rtlScreen.getByRole("table"));
|
|
112
|
+
expect(rtlScreen.queryByText("No notifications yet")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("custom emptyState renders title, description, and dispatches its action's payload evaluated against the parent record", async () => {
|
|
116
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
117
|
+
const section: EditRelatedListSectionViewModel = {
|
|
118
|
+
...baseSection,
|
|
119
|
+
emptyState: {
|
|
120
|
+
title: "No notifications yet",
|
|
121
|
+
description: "New ones show up here.",
|
|
122
|
+
action: {
|
|
123
|
+
kind: "writeHandler",
|
|
124
|
+
id: "seed",
|
|
125
|
+
label: "actions.seed",
|
|
126
|
+
handler: "orders:write:seed-notifications",
|
|
127
|
+
payload: { pick: ["id"] },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
renderEmpty(section, (type, payload) => writes.push({ type, payload }));
|
|
132
|
+
|
|
133
|
+
await waitFor(() => rtlScreen.getByText("No notifications yet"));
|
|
134
|
+
expect(rtlScreen.getByText("New ones show up here.")).toBeTruthy();
|
|
135
|
+
|
|
136
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-seed"));
|
|
137
|
+
await waitFor(() => expect(writes.length).toBe(1));
|
|
138
|
+
expect(writes[0]).toEqual({
|
|
139
|
+
type: "orders:write:seed-notifications",
|
|
140
|
+
payload: { id: "order-1" },
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -189,7 +189,7 @@ function renderRelatedList(
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
|
|
192
|
-
test("hideTitle (tabs mode) renders the list without a Section wrapper,
|
|
192
|
+
test("hideTitle (tabs mode) renders the list without a Section wrapper, drops the table frame (the tab card frames it), and marks scrollBody", async () => {
|
|
193
193
|
const { dispatcher } = stubDispatcher();
|
|
194
194
|
let capturedChromeless: boolean | undefined;
|
|
195
195
|
let capturedScrollBody: boolean | undefined;
|
|
@@ -221,7 +221,7 @@ describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
|
|
|
221
221
|
|
|
222
222
|
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
223
223
|
expect(rtlScreen.queryByTestId(`related-list-${historySection.title}`)).toBeNull();
|
|
224
|
-
expect(capturedChromeless).
|
|
224
|
+
expect(capturedChromeless).toBe(true);
|
|
225
225
|
expect(capturedScrollBody).toBe(true);
|
|
226
226
|
});
|
|
227
227
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Read-only number display (`field.readOnly: true`, no own `renderer`) goes
|
|
2
|
+
// through readOnlyDisplayText's own Intl.NumberFormat call, independent of
|
|
3
|
+
// the headless `applyFormatSpec` path already covered by
|
|
4
|
+
// format.test.tsx — `field.grouping` gates it the same way (fw#3234).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
11
|
+
import { type CorePrimitives, PrimitivesProvider, type TextProps } from "../../primitives";
|
|
12
|
+
import { RenderField } from "../render-field";
|
|
13
|
+
|
|
14
|
+
const noop = (): ReactNode => null;
|
|
15
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
16
|
+
const DefaultText = ({ testId, children }: TextProps): ReactNode => (
|
|
17
|
+
<span data-testid={testId}>{children}</span>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const testPrimitives: CorePrimitives = {
|
|
21
|
+
Button: noop,
|
|
22
|
+
Banner: noop,
|
|
23
|
+
Field: passChildren,
|
|
24
|
+
Input: noop,
|
|
25
|
+
DataTable: noop,
|
|
26
|
+
Form: noop,
|
|
27
|
+
Section: noop,
|
|
28
|
+
Card: noop,
|
|
29
|
+
Grid: noop,
|
|
30
|
+
GridCell: noop,
|
|
31
|
+
Text: DefaultText,
|
|
32
|
+
Heading: noop,
|
|
33
|
+
Dialog: noop,
|
|
34
|
+
Modal: noop,
|
|
35
|
+
Lightbox: noop,
|
|
36
|
+
ConfigSourceBadge: noop,
|
|
37
|
+
ConfigCascadeView: noop,
|
|
38
|
+
Link: noop,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function numberField(grouping: boolean | undefined): EditFieldViewModel {
|
|
42
|
+
return {
|
|
43
|
+
field: "population",
|
|
44
|
+
label: "Population",
|
|
45
|
+
type: "number",
|
|
46
|
+
value: 2021,
|
|
47
|
+
visible: true,
|
|
48
|
+
readOnly: true,
|
|
49
|
+
required: false,
|
|
50
|
+
...(grouping !== undefined && { grouping }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderReadOnly(locale: string, grouping: boolean | undefined): string {
|
|
55
|
+
cleanup();
|
|
56
|
+
render(
|
|
57
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
|
|
58
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
59
|
+
<RenderField field={numberField(grouping)} onChange={() => {}} valueDisplay="text" />
|
|
60
|
+
</PrimitivesProvider>
|
|
61
|
+
</LocaleProvider>,
|
|
62
|
+
);
|
|
63
|
+
return screen.getByTestId("field-value-population").textContent ?? "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("RenderField — read-only number grouping (fw#3234)", () => {
|
|
67
|
+
test("groups thousands by default, locale-aware", () => {
|
|
68
|
+
expect(renderReadOnly("en-US", undefined)).toBe("2,021");
|
|
69
|
+
expect(renderReadOnly("de-DE", undefined)).toBe("2.021");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("grouping: false renders the digits without a thousands separator", () => {
|
|
73
|
+
expect(renderReadOnly("en-US", false)).toBe("2021");
|
|
74
|
+
expect(renderReadOnly("de-DE", false)).toBe("2021");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -17,6 +17,7 @@ import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
|
17
17
|
import {
|
|
18
18
|
type BannerProps,
|
|
19
19
|
type ButtonProps,
|
|
20
|
+
type CardProps,
|
|
20
21
|
type CorePrimitives,
|
|
21
22
|
PrimitivesProvider,
|
|
22
23
|
type SectionProps,
|
|
@@ -66,6 +67,27 @@ const testSection: ComponentType<SectionProps> = ({ testId, subtitle, children,
|
|
|
66
67
|
</div>
|
|
67
68
|
);
|
|
68
69
|
|
|
70
|
+
// Mirrors DefaultCard's slots (title/subtitle/headerActions) closely enough
|
|
71
|
+
// to distinguish "framed via Card" from "framed via Section" in tests —
|
|
72
|
+
// tabs mode (hideTitle) uses Card because Section flattens inside this
|
|
73
|
+
// component's host Form.
|
|
74
|
+
const testCard: ComponentType<CardProps> = ({ testId, slots, children }) => (
|
|
75
|
+
<div data-testid={testId}>
|
|
76
|
+
{slots?.title !== undefined && (
|
|
77
|
+
<h3 data-testid={testId !== undefined ? `${testId}-title` : undefined}>{slots.title}</h3>
|
|
78
|
+
)}
|
|
79
|
+
{slots?.subtitle !== undefined && (
|
|
80
|
+
<p data-testid={testId !== undefined ? `${testId}-subtitle` : undefined}>{slots.subtitle}</p>
|
|
81
|
+
)}
|
|
82
|
+
<div data-testid={testId !== undefined ? `${testId}-body` : undefined}>{children}</div>
|
|
83
|
+
{slots?.headerActions !== undefined && (
|
|
84
|
+
<div data-testid={testId !== undefined ? `${testId}-actions` : undefined}>
|
|
85
|
+
{slots.headerActions}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
|
|
69
91
|
function testPrimitives(): CorePrimitives {
|
|
70
92
|
return {
|
|
71
93
|
Button: testButton,
|
|
@@ -75,7 +97,7 @@ function testPrimitives(): CorePrimitives {
|
|
|
75
97
|
DataTable: noop,
|
|
76
98
|
Form: noop,
|
|
77
99
|
Section: testSection,
|
|
78
|
-
Card:
|
|
100
|
+
Card: testCard,
|
|
79
101
|
Grid: passChildren,
|
|
80
102
|
GridCell: passChildren,
|
|
81
103
|
Text: noop,
|
|
@@ -244,6 +266,37 @@ describe("WriteFormSection", () => {
|
|
|
244
266
|
});
|
|
245
267
|
});
|
|
246
268
|
|
|
269
|
+
// Tabs mode (hideTitle) frames through Card instead of Section — Section
|
|
270
|
+
// flattens to a borderless divider inside the host RenderEdit's own Form,
|
|
271
|
+
// which would otherwise leave the submit button with no card chrome.
|
|
272
|
+
describe("WriteFormSection — tabs mode (hideTitle)", () => {
|
|
273
|
+
test("renders via Card, no title text, submit button lands in headerActions", () => {
|
|
274
|
+
const { dispatcher } = stubDispatcher();
|
|
275
|
+
render(
|
|
276
|
+
<LocaleProvider
|
|
277
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
278
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
279
|
+
>
|
|
280
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
281
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
282
|
+
<WriteFormSection
|
|
283
|
+
section={noteSection}
|
|
284
|
+
featureName="orders"
|
|
285
|
+
onSubmitted={noop}
|
|
286
|
+
hideTitle
|
|
287
|
+
/>
|
|
288
|
+
</PrimitivesProvider>
|
|
289
|
+
</DispatcherProvider>
|
|
290
|
+
</LocaleProvider>,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(rtlScreen.queryByTestId("write-form-Add note-title")).toBeNull();
|
|
294
|
+
const actions = rtlScreen.getByTestId("write-form-Add note-actions");
|
|
295
|
+
const button = rtlScreen.getByTestId("write-form-section-submit");
|
|
296
|
+
expect(actions.contains(button)).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
247
300
|
// section.description exists on EditWriteFormSection to make a self-service
|
|
248
301
|
// write form usable without training (same doc-promise as EditFieldsSection),
|
|
249
302
|
// but WriteFormSection never read it — the ViewModel translated it correctly,
|