@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.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.d.ts +14 -0
- package/{src/app/action-form-shim.ts → dist/app/action-form-shim.js} +26 -44
- package/dist/app/app-features-context.d.ts +8 -0
- package/dist/app/app-features-context.js +16 -0
- package/dist/app/column-renderers.d.ts +30 -0
- package/dist/app/column-renderers.js +34 -0
- package/dist/app/config-edit-shim.d.ts +11 -0
- package/{src/app/config-edit-shim.ts → dist/app/config-edit-shim.js} +12 -25
- package/dist/app/content-editors.d.ts +38 -0
- package/dist/app/content-editors.js +26 -0
- package/dist/app/content-preview.d.ts +11 -0
- package/dist/app/content-preview.js +30 -0
- package/dist/app/custom-screens.d.ts +11 -0
- package/dist/app/custom-screens.js +20 -0
- package/dist/app/dashboard-body.d.ts +16 -0
- package/dist/app/dashboard-body.js +11 -0
- package/dist/app/extension-form-submit.d.ts +32 -0
- package/dist/app/extension-form-submit.js +73 -0
- package/dist/app/extension-sections.d.ts +72 -0
- package/dist/app/extension-sections.js +34 -0
- package/dist/app/feature-schema.d.ts +4 -0
- package/{src/app/feature-schema.ts → dist/app/feature-schema.js} +12 -22
- package/dist/app/form-schema.d.ts +4 -0
- package/dist/app/form-schema.js +85 -0
- package/dist/app/kumiko-screen.d.ts +43 -0
- package/dist/app/kumiko-screen.js +2206 -0
- package/dist/app/layout-fields.d.ts +3 -0
- package/dist/app/layout-fields.js +20 -0
- package/dist/app/list-facets.d.ts +48 -0
- package/dist/app/list-facets.js +185 -0
- package/dist/app/nav.d.ts +61 -0
- package/dist/app/nav.js +139 -0
- package/dist/app/projection-detail-shim.d.ts +8 -0
- package/dist/app/projection-detail-shim.js +71 -0
- package/dist/app/projection-list-shim.d.ts +13 -0
- package/{src/app/projection-list-shim.ts → dist/app/projection-list-shim.js} +26 -38
- package/dist/app/qn.d.ts +7 -0
- package/{src/app/qn.ts → dist/app/qn.js} +9 -11
- package/dist/app/qualify-screen-id.d.ts +2 -0
- package/{src/app/qualify-screen-id.ts → dist/app/qualify-screen-id.js} +5 -6
- package/dist/app/reference-facet-bridge.d.ts +10 -0
- package/dist/app/reference-facet-bridge.js +22 -0
- package/dist/app/return-to.d.ts +31 -0
- package/dist/app/return-to.js +188 -0
- package/dist/app/row-actions.d.ts +77 -0
- package/dist/app/row-actions.js +416 -0
- package/dist/app/screen-access.d.ts +8 -0
- package/dist/app/screen-access.js +48 -0
- package/dist/app/secret-mint-body.d.ts +10 -0
- package/dist/app/secret-mint-body.js +140 -0
- package/dist/app/secrets-edit-body.d.ts +8 -0
- package/dist/app/secrets-edit-body.js +76 -0
- package/dist/app/use-embedded-screen.d.ts +6 -0
- package/dist/app/use-embedded-screen.js +19 -0
- package/dist/app/variable-chips.d.ts +7 -0
- package/dist/app/variable-chips.js +16 -0
- package/dist/app/write-failed-error.d.ts +6 -0
- package/dist/app/write-failed-error.js +21 -0
- package/dist/components/embedded-list-field.d.ts +10 -0
- package/dist/components/embedded-list-field.js +259 -0
- package/dist/components/grid-cell-for-field.d.ts +25 -0
- package/dist/components/grid-cell-for-field.js +31 -0
- package/dist/components/multi-select-checkboxes.d.ts +14 -0
- package/dist/components/multi-select-checkboxes.js +36 -0
- package/dist/components/reference-create-dialog.d.ts +13 -0
- package/dist/components/reference-create-dialog.js +46 -0
- package/dist/components/related-list-section.d.ts +25 -0
- package/dist/components/related-list-section.js +273 -0
- package/dist/components/render-edit-action-button.d.ts +22 -0
- package/dist/components/render-edit-action-button.js +43 -0
- package/dist/components/render-edit-logic.d.ts +6 -0
- package/dist/components/render-edit-logic.js +63 -0
- package/dist/components/render-edit-types.d.ts +209 -0
- package/dist/components/render-edit-types.js +1 -0
- package/dist/components/render-edit.d.ts +6 -0
- package/dist/components/render-edit.js +1169 -0
- package/dist/components/render-field.d.ts +38 -0
- package/dist/components/render-field.js +544 -0
- package/dist/components/render-list.d.ts +117 -0
- package/dist/components/render-list.js +232 -0
- package/dist/components/write-form-section.d.ts +17 -0
- package/dist/components/write-form-section.js +104 -0
- package/dist/context/dispatcher-context.d.ts +10 -0
- package/{src/context/dispatcher-context.tsx → dist/context/dispatcher-context.js} +16 -29
- package/dist/context/draft-storage-context.d.ts +12 -0
- package/dist/context/draft-storage-context.js +18 -0
- package/dist/context/user-roles-context.d.ts +7 -0
- package/{src/context/user-roles-context.tsx → dist/context/user-roles-context.js} +7 -15
- package/dist/format-when.d.ts +1 -0
- package/{src/format-when.ts → dist/format-when.js} +7 -7
- package/dist/hooks/reference-limits.d.ts +3 -0
- package/{src/hooks/reference-limits.ts → dist/hooks/reference-limits.js} +0 -1
- package/dist/hooks/use-ai-text.d.ts +52 -0
- package/dist/hooks/use-ai-text.js +90 -0
- package/dist/hooks/use-disclosure.d.ts +7 -0
- package/dist/hooks/use-disclosure.js +8 -0
- package/dist/hooks/use-form.d.ts +7 -0
- package/dist/hooks/use-form.js +36 -0
- package/dist/hooks/use-list-url-state.d.ts +43 -0
- package/dist/hooks/use-list-url-state.js +131 -0
- package/dist/hooks/use-mutation.d.ts +9 -0
- package/dist/hooks/use-mutation.js +37 -0
- package/dist/hooks/use-query.d.ts +14 -0
- package/dist/hooks/use-query.js +118 -0
- package/dist/hooks/use-reference-lookup.d.ts +14 -0
- package/dist/hooks/use-reference-lookup.js +63 -0
- package/dist/hooks/use-store.d.ts +3 -0
- package/dist/hooks/use-store.js +38 -0
- package/dist/hooks/use-stream-handler.d.ts +14 -0
- package/dist/hooks/use-stream-handler.js +93 -0
- package/dist/i18n-defaults.d.ts +2 -0
- package/dist/i18n-defaults.js +201 -0
- package/dist/i18n.d.ts +64 -0
- package/dist/i18n.js +166 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +55 -0
- package/dist/inside-drawer.d.ts +2 -0
- package/dist/inside-drawer.js +9 -0
- package/dist/page-header-slot.d.ts +2 -0
- package/dist/page-header-slot.js +9 -0
- package/dist/primitives.d.ts +1351 -0
- package/dist/primitives.js +73 -0
- package/dist/screen-fills-height.d.ts +3 -0
- package/dist/screen-fills-height.js +3 -0
- package/dist/share-links.d.ts +4 -0
- package/dist/share-links.js +11 -0
- package/dist/sort-by-accessor.d.ts +5 -0
- package/dist/sort-by-accessor.js +16 -0
- package/dist/sse/live-events.d.ts +23 -0
- package/dist/sse/live-events.js +28 -0
- package/dist/tokens.d.ts +83 -0
- package/dist/tokens.js +80 -0
- package/package.json +16 -9
- package/src/changes.json +52 -0
- package/src/__tests__/error-i18n-defaults.test.ts +0 -82
- package/src/__tests__/format-when.test.ts +0 -12
- package/src/__tests__/i18n.test.tsx +0 -283
- package/src/__tests__/merge-search-params-into-initial.test.ts +0 -525
- package/src/__tests__/qn.test.ts +0 -83
- package/src/__tests__/share-links.test.ts +0 -26
- package/src/__tests__/sort-by-accessor.test.ts +0 -48
- package/src/__tests__/stringify-nav-params.test.ts +0 -33
- package/src/__tests__/use-list-url-state.test.tsx +0 -250
- package/src/app/__tests__/action-form-field-labels.test.tsx +0 -92
- package/src/app/__tests__/action-form-shim.test.ts +0 -137
- package/src/app/__tests__/build-list-query-payload.test.ts +0 -107
- package/src/app/__tests__/config-edit-shim.test.ts +0 -57
- package/src/app/__tests__/content-editors.test.tsx +0 -128
- package/src/app/__tests__/content-preview.test.tsx +0 -184
- package/src/app/__tests__/entity-edit-create-search-params.test.tsx +0 -236
- package/src/app/__tests__/entity-edit-jsonb-embedded-submit.test.tsx +0 -239
- package/src/app/__tests__/entity-list-create-singleton.test.tsx +0 -128
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +0 -424
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +0 -248
- package/src/app/__tests__/entity-list-row-action-kumiko-actions-view.test.tsx +0 -162
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +0 -401
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +0 -111
- package/src/app/__tests__/feature-schema.test.ts +0 -40
- package/src/app/__tests__/form-schema.test.ts +0 -247
- package/src/app/__tests__/list-daterange-facet.test.ts +0 -165
- package/src/app/__tests__/list-filter-facets.test.tsx +0 -521
- package/src/app/__tests__/nav.test.ts +0 -198
- package/src/app/__tests__/projection-detail-actions.test.tsx +0 -719
- package/src/app/__tests__/projection-detail-reference-field.test.tsx +0 -165
- package/src/app/__tests__/projection-detail-shim.test.ts +0 -188
- package/src/app/__tests__/projection-detail-singleton.test.tsx +0 -191
- package/src/app/__tests__/projection-list-bad-shape.test.tsx +0 -152
- package/src/app/__tests__/projection-list-reference-column.test.tsx +0 -219
- package/src/app/__tests__/projection-list-search-sort.test.tsx +0 -212
- package/src/app/__tests__/qualified-names.test.ts +0 -37
- package/src/app/__tests__/return-to.test.ts +0 -396
- package/src/app/__tests__/row-actions-navigate-tab.test.ts +0 -223
- package/src/app/__tests__/row-default-edit-action.test.tsx +0 -324
- package/src/app/__tests__/screen-access-allows.test.ts +0 -34
- package/src/app/__tests__/secret-mint-body.test.tsx +0 -404
- package/src/app/__tests__/variable-chips.test.tsx +0 -70
- package/src/app/app-features-context.tsx +0 -25
- package/src/app/column-renderers.tsx +0 -68
- package/src/app/content-editors.tsx +0 -85
- package/src/app/content-preview.tsx +0 -67
- package/src/app/custom-screens.tsx +0 -29
- package/src/app/dashboard-body.tsx +0 -33
- package/src/app/extension-form-submit.tsx +0 -123
- package/src/app/extension-sections.tsx +0 -111
- package/src/app/form-schema.ts +0 -91
- package/src/app/kumiko-screen.tsx +0 -3614
- package/src/app/layout-fields.ts +0 -31
- package/src/app/list-facets.ts +0 -259
- package/src/app/nav.tsx +0 -258
- package/src/app/projection-detail-shim.ts +0 -85
- package/src/app/reference-facet-bridge.tsx +0 -58
- package/src/app/return-to.tsx +0 -234
- package/src/app/row-actions.ts +0 -639
- package/src/app/screen-access.ts +0 -63
- package/src/app/secret-mint-body.tsx +0 -259
- package/src/app/secrets-edit-body.tsx +0 -194
- package/src/app/use-embedded-screen.ts +0 -27
- package/src/app/variable-chips.tsx +0 -39
- package/src/app/write-failed-error.ts +0 -27
- package/src/components/__tests__/embedded-list-field.test.tsx +0 -531
- package/src/components/__tests__/multi-select-checkboxes.test.tsx +0 -162
- package/src/components/__tests__/related-list-section-empty-state.test.tsx +0 -143
- package/src/components/__tests__/related-list-section.test.tsx +0 -1669
- package/src/components/__tests__/render-edit-action-button.test.tsx +0 -205
- package/src/components/__tests__/render-edit-hidden-section.test.tsx +0 -128
- package/src/components/__tests__/render-edit-logic.test.ts +0 -314
- package/src/components/__tests__/render-edit-screen-description.test.tsx +0 -152
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +0 -460
- package/src/components/__tests__/render-field-app-locale.test.tsx +0 -142
- package/src/components/__tests__/render-field-enum-option.test.tsx +0 -99
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +0 -119
- package/src/components/__tests__/render-field-json-format.test.tsx +0 -164
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +0 -212
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +0 -217
- package/src/components/__tests__/render-field-number-grouping.test.tsx +0 -76
- package/src/components/__tests__/render-field-number-unit.test.tsx +0 -227
- package/src/components/__tests__/render-field-password-format.test.tsx +0 -101
- package/src/components/__tests__/render-field-select-display.test.tsx +0 -134
- package/src/components/__tests__/render-field-unit-format.test.tsx +0 -107
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +0 -195
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +0 -146
- package/src/components/__tests__/write-form-section.test.tsx +0 -375
- package/src/components/embedded-list-field.tsx +0 -354
- package/src/components/grid-cell-for-field.tsx +0 -60
- package/src/components/multi-select-checkboxes.tsx +0 -93
- package/src/components/reference-create-dialog.tsx +0 -91
- package/src/components/related-list-section.tsx +0 -467
- package/src/components/render-edit-action-button.tsx +0 -106
- package/src/components/render-edit-logic.ts +0 -85
- package/src/components/render-edit-types.ts +0 -216
- package/src/components/render-edit.tsx +0 -1700
- package/src/components/render-field.tsx +0 -942
- package/src/components/render-list.tsx +0 -561
- package/src/components/write-form-section.tsx +0 -199
- package/src/context/draft-storage-context.tsx +0 -39
- package/src/hooks/__tests__/use-ai-text.test.tsx +0 -177
- package/src/hooks/__tests__/use-disclosure.test.tsx +0 -25
- package/src/hooks/__tests__/use-form.test.tsx +0 -65
- package/src/hooks/__tests__/use-mutation.test.tsx +0 -77
- package/src/hooks/__tests__/use-stream-handler.test.tsx +0 -107
- package/src/hooks/use-ai-text.ts +0 -172
- package/src/hooks/use-disclosure.ts +0 -20
- package/src/hooks/use-form.ts +0 -78
- package/src/hooks/use-list-url-state.ts +0 -171
- package/src/hooks/use-mutation.ts +0 -61
- package/src/hooks/use-query.ts +0 -132
- package/src/hooks/use-reference-lookup.ts +0 -76
- package/src/hooks/use-store.ts +0 -48
- package/src/hooks/use-stream-handler.ts +0 -134
- package/src/i18n-defaults.ts +0 -220
- package/src/i18n.tsx +0 -286
- package/src/index.ts +0 -272
- package/src/primitives.tsx +0 -1446
- package/src/share-links.ts +0 -12
- package/src/sort-by-accessor.ts +0 -20
- package/src/sse/live-events.tsx +0 -56
- package/src/tokens.tsx +0 -170
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
EditFieldViewModel,
|
|
3
|
-
EditWriteFormSectionViewModel,
|
|
4
|
-
Translate,
|
|
5
|
-
} from "@cosmicdrift/kumiko-headless";
|
|
6
|
-
import { I18N_KEY_PARAM } from "@cosmicdrift/kumiko-headless";
|
|
7
|
-
import { type ReactNode, useMemo, useState } from "react";
|
|
8
|
-
import * as z from "zod";
|
|
9
|
-
import { REQUIRED_FIELD_I18N_KEY } from "../app/form-schema";
|
|
10
|
-
import { dispatcherErrorText } from "../app/write-failed-error";
|
|
11
|
-
import { useForm } from "../hooks/use-form";
|
|
12
|
-
import { useTranslation } from "../i18n";
|
|
13
|
-
import { usePrimitives } from "../primitives";
|
|
14
|
-
import { GridCellForField } from "./grid-cell-for-field";
|
|
15
|
-
|
|
16
|
-
// Same "has a value" rule as buildFormSchema's isPresent (app/form-schema.ts)
|
|
17
|
-
// — duplicated because that helper walks raw EditFieldSpec + EntityDefinition,
|
|
18
|
-
// unavailable here: this section only ever sees computeEditViewModel's
|
|
19
|
-
// already-resolved EditFieldViewModel[] (required/readOnly/visible are plain
|
|
20
|
-
// booleans by the time render-edit.tsx hands the section to this component).
|
|
21
|
-
function isPresent(value: unknown): boolean {
|
|
22
|
-
if (value === undefined || value === null || value === "") return false;
|
|
23
|
-
if (Array.isArray(value) && value.length === 0) return false;
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function buildWriteFormSchema(fields: readonly EditFieldViewModel[]): z.ZodType {
|
|
28
|
-
return z
|
|
29
|
-
.object({})
|
|
30
|
-
.passthrough()
|
|
31
|
-
.superRefine((values, ctx) => {
|
|
32
|
-
const record = values as Record<string, unknown>;
|
|
33
|
-
for (const field of fields) {
|
|
34
|
-
if (field.readOnly || !field.visible || !field.required) continue;
|
|
35
|
-
if (isPresent(record[field.field])) continue;
|
|
36
|
-
ctx.addIssue({
|
|
37
|
-
code: "custom",
|
|
38
|
-
path: [field.field],
|
|
39
|
-
message: `"${field.field}" is required.`,
|
|
40
|
-
params: { [I18N_KEY_PARAM]: REQUIRED_FIELD_I18N_KEY },
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export type WriteFormSectionProps = {
|
|
47
|
-
readonly section: EditWriteFormSectionViewModel;
|
|
48
|
-
readonly featureName: string;
|
|
49
|
-
readonly translate?: Translate;
|
|
50
|
-
readonly hideTitle?: boolean;
|
|
51
|
-
/** Fired after a successful submit — projectionDetail reloads its own
|
|
52
|
-
* record (a new one now exists) via a full RenderEdit remount, see
|
|
53
|
-
* ProjectionDetailBody's reloadNonce/key in kumiko-screen.tsx. */
|
|
54
|
-
readonly onSubmitted: () => void;
|
|
55
|
-
/** section.actions, already resolved into buttons by the caller —
|
|
56
|
-
* rendered alongside (before) the section's own submit button in the
|
|
57
|
-
* title-row actions slot. */
|
|
58
|
-
readonly actions?: ReactNode;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
// A self-persisting form section for projectionDetail (see
|
|
62
|
-
// EditWriteFormSection's doc). Deliberately NOT a nested <RenderEdit> — that
|
|
63
|
-
// would render a second, invalid nested <form> inside the host's own form
|
|
64
|
-
// element — so this mounts its own independent useForm() controller and
|
|
65
|
-
// submits via a plain button click instead of native form submission.
|
|
66
|
-
export function WriteFormSection({
|
|
67
|
-
section,
|
|
68
|
-
featureName,
|
|
69
|
-
translate,
|
|
70
|
-
hideTitle,
|
|
71
|
-
onSubmitted,
|
|
72
|
-
actions,
|
|
73
|
-
}: WriteFormSectionProps): ReactNode {
|
|
74
|
-
const { Section, Card, Grid, GridCell, Button, Banner } = usePrimitives();
|
|
75
|
-
const t = useTranslation();
|
|
76
|
-
const effectiveTranslate = translate ?? t;
|
|
77
|
-
|
|
78
|
-
const initial = useMemo(
|
|
79
|
-
() => Object.fromEntries(section.fields.map((f: EditFieldViewModel) => [f.field, f.value])),
|
|
80
|
-
[section.fields],
|
|
81
|
-
);
|
|
82
|
-
const schema = useMemo(() => buildWriteFormSchema(section.fields), [section.fields]);
|
|
83
|
-
const { controller, snapshot } = useForm({
|
|
84
|
-
initial,
|
|
85
|
-
submit: { type: section.handler, payloadMode: "values" },
|
|
86
|
-
schema,
|
|
87
|
-
});
|
|
88
|
-
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
89
|
-
const [error, setError] = useState<string | null>(null);
|
|
90
|
-
|
|
91
|
-
async function handleSubmit(): Promise<void> {
|
|
92
|
-
if (isSubmitting) return;
|
|
93
|
-
setIsSubmitting(true);
|
|
94
|
-
try {
|
|
95
|
-
const result = await controller.submit();
|
|
96
|
-
if (result.isSuccess) {
|
|
97
|
-
setError(null);
|
|
98
|
-
onSubmitted();
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (result.validationBlocked) return;
|
|
102
|
-
// Field-level issues already surface inline via snapshot.errors (same
|
|
103
|
-
// GridCellForField/RenderField path RenderEdit uses) — the banner is
|
|
104
|
-
// only for form-level errors nothing else would show (fw#1901 pattern).
|
|
105
|
-
const fieldIssues = result.error.details?.fields ?? [];
|
|
106
|
-
setError(
|
|
107
|
-
fieldIssues.length === 0 ? dispatcherErrorText(result.error, effectiveTranslate) : null,
|
|
108
|
-
);
|
|
109
|
-
} finally {
|
|
110
|
-
setIsSubmitting(false);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const content = (
|
|
115
|
-
<>
|
|
116
|
-
<Grid columns={section.columns}>
|
|
117
|
-
{section.fields.map((field: EditFieldViewModel) => (
|
|
118
|
-
<GridCellForField
|
|
119
|
-
key={field.field}
|
|
120
|
-
field={field}
|
|
121
|
-
columns={section.columns}
|
|
122
|
-
issues={snapshot.errors[field.field]}
|
|
123
|
-
onChange={(v) => controller.setField(field.field, v)}
|
|
124
|
-
GridCell={GridCell}
|
|
125
|
-
featureName={featureName}
|
|
126
|
-
allIssues={snapshot.errors}
|
|
127
|
-
valueDisplay="form"
|
|
128
|
-
row={snapshot.values}
|
|
129
|
-
/>
|
|
130
|
-
))}
|
|
131
|
-
</Grid>
|
|
132
|
-
{error !== null && (
|
|
133
|
-
<Banner variant="error" testId="write-form-section-error">
|
|
134
|
-
{error}
|
|
135
|
-
</Banner>
|
|
136
|
-
)}
|
|
137
|
-
</>
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
// type="button" (not "submit") is load-bearing: this section is deliberately
|
|
141
|
-
// NOT a nested <form> (see the component doc above), so a "submit" type
|
|
142
|
-
// would instead trigger the host RenderEdit's own form submit.
|
|
143
|
-
const submitButton = (
|
|
144
|
-
<Button
|
|
145
|
-
type="button"
|
|
146
|
-
variant="primary"
|
|
147
|
-
icon="check"
|
|
148
|
-
disabled={isSubmitting}
|
|
149
|
-
loading={isSubmitting}
|
|
150
|
-
onClick={() => void handleSubmit()}
|
|
151
|
-
testId="write-form-section-submit"
|
|
152
|
-
>
|
|
153
|
-
{section.submitLabel ?? effectiveTranslate("kumiko.actions.save")}
|
|
154
|
-
</Button>
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
const titleRowActions =
|
|
158
|
-
actions !== undefined ? (
|
|
159
|
-
<>
|
|
160
|
-
{actions}
|
|
161
|
-
{submitButton}
|
|
162
|
-
</>
|
|
163
|
-
) : (
|
|
164
|
-
submitButton
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
// Section always flattens to a borderless divider when rendered inside
|
|
168
|
-
// RenderEdit's own <Form> in tabs mode (hideTitle) — Card frames it there
|
|
169
|
-
// instead, same reason render-edit.tsx's tabs-mode fields/extension
|
|
170
|
-
// branches stay on Card.
|
|
171
|
-
if (hideTitle) {
|
|
172
|
-
return (
|
|
173
|
-
<Card
|
|
174
|
-
slots={{
|
|
175
|
-
...(section.description !== undefined && { subtitle: section.description }),
|
|
176
|
-
headerActions: titleRowActions,
|
|
177
|
-
}}
|
|
178
|
-
testId={`write-form-${section.title ?? "section"}`}
|
|
179
|
-
>
|
|
180
|
-
{content}
|
|
181
|
-
</Card>
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Routed through Section's `actions` slot (same mechanism render-edit.tsx
|
|
186
|
-
// uses via Form's `actions`) so the button gets the established right-
|
|
187
|
-
// aligned footer treatment instead of stretching full-width inline.
|
|
188
|
-
return (
|
|
189
|
-
<Section
|
|
190
|
-
{...(section.title !== undefined && { title: section.title })}
|
|
191
|
-
{...(section.description !== undefined && { subtitle: section.description })}
|
|
192
|
-
{...(section.icon !== undefined && { icon: section.icon })}
|
|
193
|
-
actions={titleRowActions}
|
|
194
|
-
testId={`write-form-${section.title ?? "section"}`}
|
|
195
|
-
>
|
|
196
|
-
{content}
|
|
197
|
-
</Section>
|
|
198
|
-
);
|
|
199
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
-
|
|
3
|
-
// Client-side draftId storage for RenderEdit's create-mode draftKey (issue
|
|
4
|
-
// #1913) — `${screen.id}:new:${draftId}` needs the same `draftId` across a
|
|
5
|
-
// same-tab reload so the wizard resumes the right row instead of the leader
|
|
6
|
-
// on the last-write-wins upsert. Contract only, platform-neutral — the
|
|
7
|
-
// concrete impl (window.sessionStorage on web) is injected by the platform
|
|
8
|
-
// package, same pattern as NavApi/window.history in app/nav.tsx. This
|
|
9
|
-
// package touches no browser storage itself.
|
|
10
|
-
export type DraftStorage = {
|
|
11
|
-
readonly getDraftId: (screenId: string) => string | null;
|
|
12
|
-
readonly setDraftId: (screenId: string, draftId: string) => void;
|
|
13
|
-
readonly clearDraftId: (screenId: string) => void;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// No-op default: without a mounted provider (unwired platform, or a test
|
|
17
|
-
// that doesn't care about drafts) same-tab reload just loses draftId
|
|
18
|
-
// persistence — RenderEdit falls back to its `form-draft:query:list`
|
|
19
|
-
// resume path, the same one a cleared sessionStorage takes on the web.
|
|
20
|
-
const noopDraftStorage: DraftStorage = {
|
|
21
|
-
getDraftId: () => null,
|
|
22
|
-
setDraftId: () => {},
|
|
23
|
-
clearDraftId: () => {},
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const DraftStorageContext = createContext<DraftStorage>(noopDraftStorage);
|
|
27
|
-
|
|
28
|
-
export type DraftStorageProviderProps = {
|
|
29
|
-
readonly value: DraftStorage;
|
|
30
|
-
readonly children: ReactNode;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export function DraftStorageProvider({ value, children }: DraftStorageProviderProps): ReactNode {
|
|
34
|
-
return <DraftStorageContext value={value}>{children}</DraftStorageContext>;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function useDraftStorage(): DraftStorage {
|
|
38
|
-
return useContext(DraftStorageContext);
|
|
39
|
-
}
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
-
import type { ReactNode } from "react";
|
|
5
|
-
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
-
import { useAiTextAction, useCompletion } from "../use-ai-text";
|
|
7
|
-
|
|
8
|
-
function makeDispatcher(query: Dispatcher["query"]): Dispatcher {
|
|
9
|
-
return {
|
|
10
|
-
query,
|
|
11
|
-
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
12
|
-
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
13
|
-
statusStore: {
|
|
14
|
-
getState: () => "online",
|
|
15
|
-
subscribe: () => () => {},
|
|
16
|
-
} as unknown as Dispatcher["statusStore"],
|
|
17
|
-
async *stream() {},
|
|
18
|
-
pendingWrites: () => [],
|
|
19
|
-
pendingFiles: () => [],
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function wrapperFor(dispatcher: Dispatcher) {
|
|
24
|
-
return ({ children }: { readonly children: ReactNode }) => (
|
|
25
|
-
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe("useAiTextAction", () => {
|
|
30
|
-
test("success → state='success', result carries the text", async () => {
|
|
31
|
-
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => ({
|
|
32
|
-
isSuccess: true,
|
|
33
|
-
data: {
|
|
34
|
-
type: "text",
|
|
35
|
-
text: `echo:${(payload as { text: string }).text}`,
|
|
36
|
-
usage: { inputTokens: 1, outputTokens: 1 },
|
|
37
|
-
},
|
|
38
|
-
})) as unknown as Dispatcher["query"]);
|
|
39
|
-
|
|
40
|
-
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
41
|
-
|
|
42
|
-
expect(result.current.state).toBe("idle");
|
|
43
|
-
await act(async () => {
|
|
44
|
-
await result.current.run({ mode: "correct", text: "hi" });
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
expect(result.current.state).toBe("success");
|
|
48
|
-
expect(result.current.result).toEqual({
|
|
49
|
-
type: "text",
|
|
50
|
-
text: "echo:hi",
|
|
51
|
-
usage: { inputTokens: 1, outputTokens: 1 },
|
|
52
|
-
});
|
|
53
|
-
expect(result.current.error).toBeNull();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("cap_exceeded error → state='cap-exceeded'", async () => {
|
|
57
|
-
const dispatcher = makeDispatcher((async () => ({
|
|
58
|
-
isSuccess: false,
|
|
59
|
-
error: { code: "cap_exceeded", message: "capped", i18nKey: "errors.cap" },
|
|
60
|
-
})) as unknown as Dispatcher["query"]);
|
|
61
|
-
|
|
62
|
-
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
63
|
-
|
|
64
|
-
await act(async () => {
|
|
65
|
-
await result.current.run({ mode: "correct", text: "hi" });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
expect(result.current.state).toBe("cap-exceeded");
|
|
69
|
-
expect(result.current.error?.code).toBe("cap_exceeded");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("feature_disabled error → state='unavailable' (graceful degradation)", async () => {
|
|
73
|
-
const dispatcher = makeDispatcher((async () => ({
|
|
74
|
-
isSuccess: false,
|
|
75
|
-
error: { code: "feature_disabled", message: "off", i18nKey: "errors.disabled" },
|
|
76
|
-
})) as unknown as Dispatcher["query"]);
|
|
77
|
-
|
|
78
|
-
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
79
|
-
|
|
80
|
-
await act(async () => {
|
|
81
|
-
await result.current.run({ mode: "complete", text: "hi" });
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
expect(result.current.state).toBe("unavailable");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("reset clears state/result/error back to idle", async () => {
|
|
88
|
-
const dispatcher = makeDispatcher((async () => ({
|
|
89
|
-
isSuccess: false,
|
|
90
|
-
error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
|
|
91
|
-
})) as unknown as Dispatcher["query"]);
|
|
92
|
-
|
|
93
|
-
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
94
|
-
await act(async () => {
|
|
95
|
-
await result.current.run({ mode: "correct", text: "hi" });
|
|
96
|
-
});
|
|
97
|
-
expect(result.current.state).toBe("error");
|
|
98
|
-
|
|
99
|
-
act(() => result.current.reset());
|
|
100
|
-
expect(result.current.state).toBe("idle");
|
|
101
|
-
expect(result.current.error).toBeNull();
|
|
102
|
-
expect(result.current.result).toBeNull();
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe("useCompletion", () => {
|
|
107
|
-
test("debounces requestCompletion — only the last call within the window fires", async () => {
|
|
108
|
-
let calls = 0;
|
|
109
|
-
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
|
|
110
|
-
calls++;
|
|
111
|
-
return {
|
|
112
|
-
isSuccess: true,
|
|
113
|
-
data: {
|
|
114
|
-
type: "text",
|
|
115
|
-
text: `suggestion for "${(payload as { text: string }).text}"`,
|
|
116
|
-
usage: { inputTokens: 1, outputTokens: 1 },
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
}) as unknown as Dispatcher["query"]);
|
|
120
|
-
|
|
121
|
-
const { result } = renderHook(() => useCompletion(20), { wrapper: wrapperFor(dispatcher) });
|
|
122
|
-
|
|
123
|
-
act(() => {
|
|
124
|
-
result.current.requestCompletion("a");
|
|
125
|
-
result.current.requestCompletion("ab");
|
|
126
|
-
result.current.requestCompletion("abc");
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
await waitFor(() => expect(result.current.suggestion).not.toBeNull(), { timeout: 1000 });
|
|
130
|
-
|
|
131
|
-
expect(calls).toBe(1);
|
|
132
|
-
expect(result.current.suggestion).toBe('suggestion for "abc"');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("empty text resets immediately without a request", async () => {
|
|
136
|
-
let calls = 0;
|
|
137
|
-
const dispatcher = makeDispatcher((async () => {
|
|
138
|
-
calls++;
|
|
139
|
-
return {
|
|
140
|
-
isSuccess: true,
|
|
141
|
-
data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
142
|
-
};
|
|
143
|
-
}) as unknown as Dispatcher["query"]);
|
|
144
|
-
|
|
145
|
-
const { result } = renderHook(() => useCompletion(10), { wrapper: wrapperFor(dispatcher) });
|
|
146
|
-
|
|
147
|
-
act(() => {
|
|
148
|
-
result.current.requestCompletion("");
|
|
149
|
-
});
|
|
150
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
151
|
-
|
|
152
|
-
expect(calls).toBe(0);
|
|
153
|
-
expect(result.current.suggestion).toBeNull();
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test("clear cancels a pending debounce and resets the suggestion", async () => {
|
|
157
|
-
let calls = 0;
|
|
158
|
-
const dispatcher = makeDispatcher((async () => {
|
|
159
|
-
calls++;
|
|
160
|
-
return {
|
|
161
|
-
isSuccess: true,
|
|
162
|
-
data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
163
|
-
};
|
|
164
|
-
}) as unknown as Dispatcher["query"]);
|
|
165
|
-
|
|
166
|
-
const { result } = renderHook(() => useCompletion(30), { wrapper: wrapperFor(dispatcher) });
|
|
167
|
-
|
|
168
|
-
act(() => {
|
|
169
|
-
result.current.requestCompletion("hello");
|
|
170
|
-
});
|
|
171
|
-
act(() => result.current.clear());
|
|
172
|
-
await new Promise((r) => setTimeout(r, 60));
|
|
173
|
-
|
|
174
|
-
expect(calls).toBe(0);
|
|
175
|
-
expect(result.current.suggestion).toBeNull();
|
|
176
|
-
});
|
|
177
|
-
});
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { act, renderHook } from "@testing-library/react";
|
|
3
|
-
import { useDisclosure } from "../use-disclosure";
|
|
4
|
-
|
|
5
|
-
describe("useDisclosure", () => {
|
|
6
|
-
test("open/close/toggle steuern den Zustand", () => {
|
|
7
|
-
const { result } = renderHook(() => useDisclosure());
|
|
8
|
-
expect(result.current.open).toBe(false);
|
|
9
|
-
act(() => result.current.onOpen());
|
|
10
|
-
expect(result.current.open).toBe(true);
|
|
11
|
-
act(() => result.current.onClose());
|
|
12
|
-
expect(result.current.open).toBe(false);
|
|
13
|
-
act(() => result.current.onToggle());
|
|
14
|
-
expect(result.current.open).toBe(true);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test("Callbacks sind referenz-stabil über Re-Renders", () => {
|
|
18
|
-
const { result, rerender } = renderHook(() => useDisclosure(true));
|
|
19
|
-
const first = result.current;
|
|
20
|
-
rerender();
|
|
21
|
-
expect(result.current.onOpen).toBe(first.onOpen);
|
|
22
|
-
expect(result.current.onClose).toBe(first.onClose);
|
|
23
|
-
expect(result.current.onToggle).toBe(first.onToggle);
|
|
24
|
-
});
|
|
25
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import type { Dispatcher, WriteResult } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { act, renderHook } from "@testing-library/react";
|
|
4
|
-
import { useForm } from "../use-form";
|
|
5
|
-
|
|
6
|
-
function makeDispatcher(response?: WriteResult): Dispatcher & {
|
|
7
|
-
readonly writeSpy: ReturnType<typeof mock>;
|
|
8
|
-
} {
|
|
9
|
-
const writeSpy = mock(
|
|
10
|
-
async () => response ?? ({ isSuccess: true, data: { id: "srv-1" } } as WriteResult),
|
|
11
|
-
);
|
|
12
|
-
return {
|
|
13
|
-
writeSpy,
|
|
14
|
-
write: writeSpy as unknown as Dispatcher["write"],
|
|
15
|
-
query: (async () => ({ isSuccess: true, data: null })) as unknown as Dispatcher["query"],
|
|
16
|
-
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
17
|
-
async *stream() {},
|
|
18
|
-
statusStore: {
|
|
19
|
-
getState: () => "online",
|
|
20
|
-
subscribe: () => () => {},
|
|
21
|
-
} as unknown as Dispatcher["statusStore"],
|
|
22
|
-
pendingWrites: () => [],
|
|
23
|
-
pendingFiles: () => [],
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("useForm — mounted without a DispatcherProvider", () => {
|
|
28
|
-
test("does not throw on mount — fw#1999 regression", () => {
|
|
29
|
-
const { result } = renderHook(() =>
|
|
30
|
-
useForm({ initial: { title: "" }, submit: { type: "app:write:task:create" } }),
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
expect(result.current.snapshot.values.title).toBe("");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("submit() without an explicit dispatcher returns isSuccess:false instead of crashing the hook", async () => {
|
|
37
|
-
const { result } = renderHook(() =>
|
|
38
|
-
useForm({ initial: { title: "hello" }, submit: { type: "app:write:task:create" } }),
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
const submitResult = await result.current.controller.submit();
|
|
42
|
-
expect(submitResult.validationBlocked).toBe(false);
|
|
43
|
-
expect(submitResult.isSuccess).toBe(false);
|
|
44
|
-
if (submitResult.isSuccess || submitResult.validationBlocked) {
|
|
45
|
-
throw new Error("expected write failure");
|
|
46
|
-
}
|
|
47
|
-
expect(submitResult.error.message).toMatch(/submit\(\) called without a dispatcher/);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("submit() with an explicit dispatcher still works without a provider", async () => {
|
|
51
|
-
const dispatcher = makeDispatcher();
|
|
52
|
-
const { result } = renderHook(() =>
|
|
53
|
-
useForm({
|
|
54
|
-
initial: { title: "hello" },
|
|
55
|
-
submit: { type: "app:write:task:create", dispatcher },
|
|
56
|
-
}),
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
await act(async () => {
|
|
60
|
-
await result.current.controller.submit();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
expect(dispatcher.writeSpy).toHaveBeenCalledWith("app:write:task:create", { title: "hello" });
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
-
import type { ReactNode } from "react";
|
|
5
|
-
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
-
import { useMutation } from "../use-mutation";
|
|
7
|
-
|
|
8
|
-
function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
|
|
9
|
-
return {
|
|
10
|
-
write,
|
|
11
|
-
query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
|
|
12
|
-
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
13
|
-
statusStore: {
|
|
14
|
-
getState: () => "online",
|
|
15
|
-
subscribe: () => () => {},
|
|
16
|
-
} as unknown as Dispatcher["statusStore"],
|
|
17
|
-
async *stream() {},
|
|
18
|
-
pendingWrites: () => [],
|
|
19
|
-
pendingFiles: () => [],
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function wrapperFor(dispatcher: Dispatcher) {
|
|
24
|
-
return ({ children }: { readonly children: ReactNode }) => (
|
|
25
|
-
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe("useMutation", () => {
|
|
30
|
-
test("Success setzt data, pending toggelt, Result wird durchgereicht", async () => {
|
|
31
|
-
let resolve: (() => void) | undefined;
|
|
32
|
-
const gate = new Promise<void>((r) => {
|
|
33
|
-
resolve = r;
|
|
34
|
-
});
|
|
35
|
-
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
|
|
36
|
-
await gate;
|
|
37
|
-
return { isSuccess: true, data: { echoed: payload } };
|
|
38
|
-
}) as unknown as Dispatcher["write"]);
|
|
39
|
-
|
|
40
|
-
const { result } = renderHook(() => useMutation<{ echoed: unknown }>("f:write:x:create"), {
|
|
41
|
-
wrapper: wrapperFor(dispatcher),
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
expect(result.current.pending).toBe(false);
|
|
45
|
-
let outcome: Awaited<ReturnType<typeof result.current.mutate>> | undefined;
|
|
46
|
-
act(() => {
|
|
47
|
-
void result.current.mutate({ name: "a" }).then((r) => {
|
|
48
|
-
outcome = r;
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
await waitFor(() => expect(result.current.pending).toBe(true));
|
|
52
|
-
act(() => resolve?.());
|
|
53
|
-
await waitFor(() => expect(result.current.pending).toBe(false));
|
|
54
|
-
expect(result.current.data).toEqual({ echoed: { name: "a" } });
|
|
55
|
-
expect(result.current.error).toBeNull();
|
|
56
|
-
expect(outcome?.isSuccess).toBe(true);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("Failure setzt error, reset räumt auf", async () => {
|
|
60
|
-
const dispatcher = makeDispatcher((async () => ({
|
|
61
|
-
isSuccess: false,
|
|
62
|
-
error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
|
|
63
|
-
})) as unknown as Dispatcher["write"]);
|
|
64
|
-
|
|
65
|
-
const { result } = renderHook(() => useMutation("f:write:x:create"), {
|
|
66
|
-
wrapper: wrapperFor(dispatcher),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
await act(async () => {
|
|
70
|
-
await result.current.mutate({});
|
|
71
|
-
});
|
|
72
|
-
expect(result.current.error?.code).toBe("conflict");
|
|
73
|
-
act(() => result.current.reset());
|
|
74
|
-
expect(result.current.error).toBeNull();
|
|
75
|
-
expect(result.current.data).toBeNull();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
3
|
-
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
-
import type { ReactNode } from "react";
|
|
5
|
-
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
-
import { useStreamHandler } from "../use-stream-handler";
|
|
7
|
-
|
|
8
|
-
function makeDispatcher(streamImpl: unknown): Dispatcher {
|
|
9
|
-
return {
|
|
10
|
-
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
11
|
-
query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
|
|
12
|
-
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
13
|
-
stream: streamImpl as Dispatcher["stream"],
|
|
14
|
-
statusStore: {
|
|
15
|
-
getState: () => "online",
|
|
16
|
-
subscribe: () => () => {},
|
|
17
|
-
} as unknown as Dispatcher["statusStore"],
|
|
18
|
-
pendingWrites: () => [],
|
|
19
|
-
pendingFiles: () => [],
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function wrapperFor(dispatcher: Dispatcher) {
|
|
24
|
-
return ({ children }: { readonly children: ReactNode }) => (
|
|
25
|
-
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe("useStreamHandler", () => {
|
|
30
|
-
test("start accumulates chunks then status=done", async () => {
|
|
31
|
-
const dispatcher = makeDispatcher(async function* () {
|
|
32
|
-
yield { i: 0 };
|
|
33
|
-
yield { i: 1 };
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
|
|
37
|
-
wrapper: wrapperFor(dispatcher),
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
await act(async () => {
|
|
41
|
-
await result.current.start({ count: 2 });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
await waitFor(() => expect(result.current.status).toBe("done"));
|
|
45
|
-
expect(result.current.chunks).toEqual([{ i: 0 }, { i: 1 }]);
|
|
46
|
-
expect(result.current.error).toBeNull();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("stream error sets status=error and error envelope", async () => {
|
|
50
|
-
const err: DispatcherError = {
|
|
51
|
-
code: "access_denied",
|
|
52
|
-
httpStatus: 403,
|
|
53
|
-
i18nKey: "errors.access",
|
|
54
|
-
message: "denied",
|
|
55
|
-
};
|
|
56
|
-
const dispatcher = makeDispatcher(async function* () {
|
|
57
|
-
yield* []; // satisfy generator shape; error is the only outcome
|
|
58
|
-
throw err;
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
const { result } = renderHook(() => useStreamHandler("f:stream:x:tail"), {
|
|
62
|
-
wrapper: wrapperFor(dispatcher),
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
await act(async () => {
|
|
66
|
-
await result.current.start();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
await waitFor(() => expect(result.current.status).toBe("error"));
|
|
70
|
-
expect(result.current.error?.code).toBe("access_denied");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("abort during stream resets status to idle (no late done)", async () => {
|
|
74
|
-
let release!: () => void;
|
|
75
|
-
const gate = new Promise<void>((r) => {
|
|
76
|
-
release = r;
|
|
77
|
-
});
|
|
78
|
-
const dispatcher = makeDispatcher(async function* (
|
|
79
|
-
_t: string,
|
|
80
|
-
_p: unknown,
|
|
81
|
-
opts?: { signal?: AbortSignal },
|
|
82
|
-
) {
|
|
83
|
-
yield { i: 0 };
|
|
84
|
-
await gate;
|
|
85
|
-
if (opts?.signal?.aborted) {
|
|
86
|
-
const e = { code: "aborted", httpStatus: 0, i18nKey: "x", message: "aborted" };
|
|
87
|
-
throw e;
|
|
88
|
-
}
|
|
89
|
-
yield { i: 1 };
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const { result } = renderHook(() => useStreamHandler<{ i: number }>("f:stream:x:tail"), {
|
|
93
|
-
wrapper: wrapperFor(dispatcher),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
let started!: Promise<void>;
|
|
97
|
-
act(() => {
|
|
98
|
-
started = result.current.start();
|
|
99
|
-
});
|
|
100
|
-
await waitFor(() => expect(result.current.chunks).toEqual([{ i: 0 }]));
|
|
101
|
-
act(() => result.current.abort());
|
|
102
|
-
release();
|
|
103
|
-
await started;
|
|
104
|
-
expect(result.current.chunks).toEqual([{ i: 0 }]);
|
|
105
|
-
expect(result.current.status).toBe("idle");
|
|
106
|
-
});
|
|
107
|
-
});
|