@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
package/src/hooks/use-ai-text.ts
DELETED
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { useDispatcher } from "../context/dispatcher-context";
|
|
4
|
-
|
|
5
|
-
// AiTextField/AiTextArea's client-side surface. Deliberately duplicates the
|
|
6
|
-
// wire-contract shape from kumiko-enterprise's `ai-text` feature instead of
|
|
7
|
-
// importing it — this package is public NPM, ai-text is a private Enterprise
|
|
8
|
-
// package, and the widgets only ever talk HTTP (ai-text-primitive plan doc,
|
|
9
|
-
// Architecture Decision 1). Keep these in lockstep with
|
|
10
|
-
// kumiko-enterprise/packages/ai-text/src/{modes,feature}.ts by hand; there's
|
|
11
|
-
// no compile-time link between the two repos.
|
|
12
|
-
|
|
13
|
-
export const AI_TEXT_RUN_QN = "ai-text:query:run";
|
|
14
|
-
|
|
15
|
-
export type AiTextMode = "complete" | "correct" | "translate" | "rewrite";
|
|
16
|
-
export type AiTextRewriteStyle = "formal" | "casual" | "concise" | "expand";
|
|
17
|
-
|
|
18
|
-
export type AiTextRunPayload =
|
|
19
|
-
| { readonly mode: "complete"; readonly text: string }
|
|
20
|
-
| { readonly mode: "correct"; readonly text: string }
|
|
21
|
-
| { readonly mode: "translate"; readonly text: string; readonly targetLanguage: string }
|
|
22
|
-
| { readonly mode: "rewrite"; readonly text: string; readonly style?: AiTextRewriteStyle };
|
|
23
|
-
|
|
24
|
-
export type AiTextUsage = { readonly inputTokens: number; readonly outputTokens: number };
|
|
25
|
-
|
|
26
|
-
export type AiTextRunResult =
|
|
27
|
-
| { readonly type: "text"; readonly text: string; readonly usage: AiTextUsage }
|
|
28
|
-
| { readonly type: "error"; readonly reason: string; readonly usage: AiTextUsage };
|
|
29
|
-
|
|
30
|
-
// =============================================================================
|
|
31
|
-
// useAiTextAction — one-shot request/response, any mode
|
|
32
|
-
// =============================================================================
|
|
33
|
-
//
|
|
34
|
-
// v1 has no streaming (ai-text-primitive plan doc, sequencing note —
|
|
35
|
-
// SSE-with-auth-reuse needs a framework-core `r.streamHandler` primitive,
|
|
36
|
-
// tracked separately). `complete` goes through this same request/response
|
|
37
|
-
// path as correct/translate/rewrite; `useCompletion` below adds debounce on
|
|
38
|
-
// top for the ghost-text use-case specifically.
|
|
39
|
-
|
|
40
|
-
export type AiTextActionState =
|
|
41
|
-
| "idle"
|
|
42
|
-
| "loading"
|
|
43
|
-
| "success"
|
|
44
|
-
| "error"
|
|
45
|
-
| "cap-exceeded"
|
|
46
|
-
| "unavailable";
|
|
47
|
-
|
|
48
|
-
export type UseAiTextActionResult = {
|
|
49
|
-
readonly run: (payload: AiTextRunPayload) => Promise<AiTextRunResult | null>;
|
|
50
|
-
readonly state: AiTextActionState;
|
|
51
|
-
readonly result: AiTextRunResult | null;
|
|
52
|
-
readonly error: DispatcherError | null;
|
|
53
|
-
readonly reset: () => void;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
function stateForError(error: DispatcherError): AiTextActionState {
|
|
57
|
-
if (error.code === "cap_exceeded" || error.code === "rate_limited") return "cap-exceeded";
|
|
58
|
-
if (error.code === "feature_disabled") return "unavailable";
|
|
59
|
-
return "error";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function useAiTextAction(): UseAiTextActionResult {
|
|
63
|
-
const dispatcher = useDispatcher();
|
|
64
|
-
const [state, setState] = useState<AiTextActionState>("idle");
|
|
65
|
-
const [result, setResult] = useState<AiTextRunResult | null>(null);
|
|
66
|
-
const [error, setError] = useState<DispatcherError | null>(null);
|
|
67
|
-
|
|
68
|
-
// Track the in-flight call so a newer `run()` cancels an older one, and
|
|
69
|
-
// unmount doesn't set state after the component is gone — same pattern
|
|
70
|
-
// as useQuery's activeCtrl.
|
|
71
|
-
const activeCtrl = useRef<AbortController | null>(null);
|
|
72
|
-
useEffect(() => {
|
|
73
|
-
return () => {
|
|
74
|
-
activeCtrl.current?.abort();
|
|
75
|
-
};
|
|
76
|
-
}, []);
|
|
77
|
-
|
|
78
|
-
const run = useCallback(
|
|
79
|
-
async (payload: AiTextRunPayload): Promise<AiTextRunResult | null> => {
|
|
80
|
-
activeCtrl.current?.abort();
|
|
81
|
-
const ctrl = new AbortController();
|
|
82
|
-
activeCtrl.current = ctrl;
|
|
83
|
-
|
|
84
|
-
setState("loading");
|
|
85
|
-
setError(null);
|
|
86
|
-
|
|
87
|
-
const res = await dispatcher.query<AiTextRunResult>(AI_TEXT_RUN_QN, payload, {
|
|
88
|
-
signal: ctrl.signal,
|
|
89
|
-
});
|
|
90
|
-
if (ctrl.signal.aborted) return null;
|
|
91
|
-
|
|
92
|
-
if (!res.isSuccess) {
|
|
93
|
-
if (res.error.code === "aborted") return null;
|
|
94
|
-
setError(res.error);
|
|
95
|
-
setState(stateForError(res.error));
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
setResult(res.data);
|
|
100
|
-
setState("success");
|
|
101
|
-
return res.data;
|
|
102
|
-
},
|
|
103
|
-
[dispatcher],
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
const reset = useCallback(() => {
|
|
107
|
-
activeCtrl.current?.abort();
|
|
108
|
-
setState("idle");
|
|
109
|
-
setResult(null);
|
|
110
|
-
setError(null);
|
|
111
|
-
}, []);
|
|
112
|
-
|
|
113
|
-
return { run, state, result, error, reset };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// =============================================================================
|
|
117
|
-
// useCompletion — debounced ghost-text for AiTextField/AiTextArea
|
|
118
|
-
// =============================================================================
|
|
119
|
-
//
|
|
120
|
-
// Debounce exists to keep the request-rate down against the monthly cap,
|
|
121
|
-
// not for UX polish — every keystroke would otherwise burn a request.
|
|
122
|
-
|
|
123
|
-
export type UseCompletionResult = {
|
|
124
|
-
readonly suggestion: string | null;
|
|
125
|
-
readonly state: AiTextActionState;
|
|
126
|
-
readonly error: DispatcherError | null;
|
|
127
|
-
/** Debounced — schedules a completion request `debounceMs` after the
|
|
128
|
-
* last call. Calling again before the timer fires replaces it. */
|
|
129
|
-
readonly requestCompletion: (text: string) => void;
|
|
130
|
-
/** Cancels any pending/in-flight request and clears the suggestion. */
|
|
131
|
-
readonly clear: () => void;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const DEFAULT_DEBOUNCE_MS = 500;
|
|
135
|
-
|
|
136
|
-
export function useCompletion(debounceMs: number = DEFAULT_DEBOUNCE_MS): UseCompletionResult {
|
|
137
|
-
const { run, state, result, error, reset } = useAiTextAction();
|
|
138
|
-
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
139
|
-
|
|
140
|
-
const clearTimer = useCallback(() => {
|
|
141
|
-
if (timerRef.current !== undefined) {
|
|
142
|
-
clearTimeout(timerRef.current);
|
|
143
|
-
timerRef.current = undefined;
|
|
144
|
-
}
|
|
145
|
-
}, []);
|
|
146
|
-
|
|
147
|
-
const requestCompletion = useCallback(
|
|
148
|
-
(text: string) => {
|
|
149
|
-
clearTimer();
|
|
150
|
-
if (text.length === 0) {
|
|
151
|
-
reset();
|
|
152
|
-
// skip: empty text, state already reset above
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
timerRef.current = setTimeout(() => {
|
|
156
|
-
void run({ mode: "complete", text });
|
|
157
|
-
}, debounceMs);
|
|
158
|
-
},
|
|
159
|
-
[clearTimer, reset, run, debounceMs],
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
const clear = useCallback(() => {
|
|
163
|
-
clearTimer();
|
|
164
|
-
reset();
|
|
165
|
-
}, [clearTimer, reset]);
|
|
166
|
-
|
|
167
|
-
useEffect(() => clearTimer, [clearTimer]);
|
|
168
|
-
|
|
169
|
-
const suggestion = result?.type === "text" ? result.text : null;
|
|
170
|
-
|
|
171
|
-
return { suggestion, state, error, requestCompletion, clear };
|
|
172
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { useCallback, useState } from "react";
|
|
2
|
-
|
|
3
|
-
// Open/close state for dialogs, sheets and collapsibles — the standard
|
|
4
|
-
// replacement for the hand-rolled `useState(false)` + toggle-callback
|
|
5
|
-
// trio in app screens. All callbacks are referentially stable.
|
|
6
|
-
|
|
7
|
-
export type UseDisclosureResult = {
|
|
8
|
-
readonly open: boolean;
|
|
9
|
-
readonly onOpen: () => void;
|
|
10
|
-
readonly onClose: () => void;
|
|
11
|
-
readonly onToggle: () => void;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export function useDisclosure(initialOpen = false): UseDisclosureResult {
|
|
15
|
-
const [open, setOpen] = useState(initialOpen);
|
|
16
|
-
const onOpen = useCallback(() => setOpen(true), []);
|
|
17
|
-
const onClose = useCallback(() => setOpen(false), []);
|
|
18
|
-
const onToggle = useCallback(() => setOpen((prev) => !prev), []);
|
|
19
|
-
return { open, onOpen, onClose, onToggle };
|
|
20
|
-
}
|
package/src/hooks/use-form.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
FormController,
|
|
3
|
-
FormControllerOptions,
|
|
4
|
-
FormSnapshot,
|
|
5
|
-
FormValues,
|
|
6
|
-
} from "@cosmicdrift/kumiko-headless";
|
|
7
|
-
import { createFormController } from "@cosmicdrift/kumiko-headless";
|
|
8
|
-
import { useMemo, useSyncExternalStore } from "react";
|
|
9
|
-
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
10
|
-
|
|
11
|
-
// Thin React wrapper around createFormController. Returns both the
|
|
12
|
-
// controller (imperative — setField, submit, reset) and the current
|
|
13
|
-
// snapshot (reactive — values, errors, isDirty). React subscribes via
|
|
14
|
-
// useSyncExternalStore so re-renders happen exactly when the snapshot
|
|
15
|
-
// reference changes, which the controller guarantees.
|
|
16
|
-
//
|
|
17
|
-
// The `submit.dispatcher` config is filled from context if omitted,
|
|
18
|
-
// so the typical call site is just:
|
|
19
|
-
// const { snapshot, controller } = useForm({
|
|
20
|
-
// initial: { ... },
|
|
21
|
-
// schema,
|
|
22
|
-
// submit: { type: "foo:create" },
|
|
23
|
-
// });
|
|
24
|
-
// and the hook wires the ambient DispatcherProvider's dispatcher in.
|
|
25
|
-
|
|
26
|
-
export type UseFormOptions<TValues extends FormValues, TCtx = unknown> = FormControllerOptions<
|
|
27
|
-
TValues,
|
|
28
|
-
TCtx
|
|
29
|
-
>;
|
|
30
|
-
|
|
31
|
-
export type UseFormResult<TValues extends FormValues> = {
|
|
32
|
-
readonly controller: FormController<TValues>;
|
|
33
|
-
readonly snapshot: FormSnapshot<TValues>;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export function useForm<TValues extends FormValues, TCtx = unknown>(
|
|
37
|
-
options: UseFormOptions<TValues, TCtx>,
|
|
38
|
-
): UseFormResult<TValues> {
|
|
39
|
-
// Optional: a form with no `submit` config (or one with an explicit
|
|
40
|
-
// dispatcher) never needs the ambient one, and mounting without a
|
|
41
|
-
// DispatcherProvider must not crash just because useForm was called.
|
|
42
|
-
const contextDispatcher = useOptionalDispatcher();
|
|
43
|
-
|
|
44
|
-
// The controller is created once per mount. `options` mutates across
|
|
45
|
-
// renders in normal React usage (closures get new references), but
|
|
46
|
-
// the controller's behaviour depends only on the initial shape — we
|
|
47
|
-
// deliberately don't re-create on option-identity change, which
|
|
48
|
-
// would wipe in-flight state and "forget" dirty edits on every
|
|
49
|
-
// parent re-render. If the app needs a reset, call controller.reset()
|
|
50
|
-
// or re-mount the form (key prop).
|
|
51
|
-
// Controller lifetime = hook lifetime. Options deliberately NOT in
|
|
52
|
-
// the deps array; re-creating on option-identity change would wipe
|
|
53
|
-
// in-flight state and dirty edits on every parent re-render.
|
|
54
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — controller is lifetime-scoped, not render-scoped
|
|
55
|
-
const controller = useMemo<FormController<TValues>>(() => {
|
|
56
|
-
// Default submit.dispatcher to the ambient one when omitted.
|
|
57
|
-
type FullOptions = FormControllerOptions<TValues, TCtx>;
|
|
58
|
-
const rawSubmit = options.submit;
|
|
59
|
-
const submitConfig: FullOptions["submit"] = rawSubmit
|
|
60
|
-
? { ...rawSubmit, dispatcher: rawSubmit.dispatcher ?? contextDispatcher }
|
|
61
|
-
: undefined;
|
|
62
|
-
const controllerOptions: FullOptions = {
|
|
63
|
-
...(options as FullOptions),
|
|
64
|
-
...(submitConfig !== undefined && { submit: submitConfig }),
|
|
65
|
-
};
|
|
66
|
-
return createFormController<TValues, TCtx>(controllerOptions);
|
|
67
|
-
}, []);
|
|
68
|
-
|
|
69
|
-
const snapshot = useSyncExternalStore(
|
|
70
|
-
(notify) => controller.subscribe(notify),
|
|
71
|
-
() => controller.getSnapshot(),
|
|
72
|
-
// SSR snapshot: same as getSnapshot(). The controller's initial
|
|
73
|
-
// snapshot is deterministic from options.initial, safe for server.
|
|
74
|
-
() => controller.getSnapshot(),
|
|
75
|
-
);
|
|
76
|
-
|
|
77
|
-
return { controller, snapshot };
|
|
78
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
// useListUrlState — bündelt sort/dir/page/q einer entityList in einen
|
|
2
|
-
// per-screen-id namespaced URL-State. Mit Screen-ID-Prefix damit zwei
|
|
3
|
-
// Listen auf derselben Route (z.B. Dashboard mit Orders + Incidents)
|
|
4
|
-
// nicht über dieselben Query-Keys streiten.
|
|
5
|
-
//
|
|
6
|
-
// Param-Schema pro Liste:
|
|
7
|
-
// <screenId>.sort — field name (string)
|
|
8
|
-
// <screenId>.dir — "asc" | "desc"
|
|
9
|
-
// <screenId>.q — search term (URL-encoded)
|
|
10
|
-
// <screenId>.page — 1-based page number (nur bei pagination="pages")
|
|
11
|
-
// <screenId>.f.<field> — Faceted-Filter: comma-joined selected values
|
|
12
|
-
//
|
|
13
|
-
// Schreibt mit setSearchParams (replaceState — kein push), damit
|
|
14
|
-
// Sort/Filter-Toggles nicht die Browser-History fluten.
|
|
15
|
-
|
|
16
|
-
import { useCallback, useMemo } from "react";
|
|
17
|
-
import { useNav } from "../app/nav";
|
|
18
|
-
import type { DataTableSort, DataTableSortDir } from "../primitives";
|
|
19
|
-
|
|
20
|
-
// ListSort + DataTableSort hatten dieselbe Shape und drohten zu driften
|
|
21
|
-
// — aliased auf den primitives-Type (eine Quelle, kein Cast in RenderList).
|
|
22
|
-
export type ListSortDir = DataTableSortDir;
|
|
23
|
-
export type ListSort = DataTableSort;
|
|
24
|
-
|
|
25
|
-
export type ListUrlState = {
|
|
26
|
-
/** Aktive Sortierung (oder null = unsorted, Server liefert Default-Order). */
|
|
27
|
-
readonly sort: ListSort | null;
|
|
28
|
-
/** Search-Term (leer wenn nicht gesetzt). */
|
|
29
|
-
readonly q: string;
|
|
30
|
-
/** 1-basierte Page-Nummer. Bei pagination="infinite" oder false ist
|
|
31
|
-
* der Wert ignoriert; Caller liest ihn nur wenn relevant. */
|
|
32
|
-
readonly page: number;
|
|
33
|
-
/** Aktive Faceted-Filter: field → ausgewählte Werte (Strings; Caller
|
|
34
|
-
* coerced pro Field-Type). Leer wenn kein Filter gesetzt. */
|
|
35
|
-
readonly filters: Readonly<Record<string, readonly string[]>>;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export type ListUrlStateApi = ListUrlState & {
|
|
39
|
-
/** Setzt sort + dir atomar. null = unsorted (löscht beide Keys). */
|
|
40
|
-
readonly setSort: (next: ListSort | null) => void;
|
|
41
|
-
/** Setzt den Search-Term. Empty-String löscht den Key. Caller debounced
|
|
42
|
-
* selber (z.B. useDebouncedCallback in der Search-Input-Komponente). */
|
|
43
|
-
readonly setQ: (next: string) => void;
|
|
44
|
-
/** Setzt die Page. 1 oder kleiner löscht den Key (Default-Page). */
|
|
45
|
-
readonly setPage: (next: number) => void;
|
|
46
|
-
/** Setzt die ausgewählten Werte eines Facet-Felds. Leeres Array löscht
|
|
47
|
-
* den Key. Resettet die Page (wie sort/search). */
|
|
48
|
-
readonly setFilter: (field: string, values: readonly string[]) => void;
|
|
49
|
-
/** Löscht alle aktiven Faceted-Filter (Reset-Button). */
|
|
50
|
-
readonly clearFilters: () => void;
|
|
51
|
-
/** Sets both bounds of a dateRange facet in ONE params update — two
|
|
52
|
-
* setFilter calls would overwrite each other, since the second still sees
|
|
53
|
-
* the old searchParams. An empty string clears that bound. Resets the page
|
|
54
|
-
* like setFilter. */
|
|
55
|
-
readonly setDateRange: (
|
|
56
|
-
field: string,
|
|
57
|
-
next: { readonly from: string; readonly to: string },
|
|
58
|
-
) => void;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
// `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
|
|
62
|
-
// üblichen Field-Namen (kebab- oder camelCase ohne Punkt). Boot-Validator
|
|
63
|
-
// pinnt screen.id ohne Punkt — siehe boot-validator entityList Section.
|
|
64
|
-
function key(screenId: string, suffix: string): string {
|
|
65
|
-
return `${screenId}.${suffix}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function parseDir(value: string | undefined): ListSortDir | undefined {
|
|
69
|
-
return value === "asc" || value === "desc" ? value : undefined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function parsePage(value: string | undefined): number {
|
|
73
|
-
if (value === undefined) return 1;
|
|
74
|
-
const n = Number.parseInt(value, 10);
|
|
75
|
-
return Number.isFinite(n) && n > 0 ? n : 1;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function useListUrlState(screenId: string): ListUrlStateApi {
|
|
79
|
-
const nav = useNav();
|
|
80
|
-
const params = nav.searchParams;
|
|
81
|
-
|
|
82
|
-
const sort = useMemo<ListSort | null>(() => {
|
|
83
|
-
const field = params[key(screenId, "sort")];
|
|
84
|
-
const dir = parseDir(params[key(screenId, "dir")]);
|
|
85
|
-
if (field === undefined || field === "" || dir === undefined) return null;
|
|
86
|
-
return { field, dir };
|
|
87
|
-
}, [params, screenId]);
|
|
88
|
-
|
|
89
|
-
const q = params[key(screenId, "q")] ?? "";
|
|
90
|
-
const page = parsePage(params[key(screenId, "page")]);
|
|
91
|
-
|
|
92
|
-
const filterPrefix = `${screenId}.f.`;
|
|
93
|
-
const filters = useMemo<Readonly<Record<string, readonly string[]>>>(() => {
|
|
94
|
-
const out: Record<string, readonly string[]> = {};
|
|
95
|
-
for (const [k, v] of Object.entries(params)) {
|
|
96
|
-
if (!k.startsWith(filterPrefix) || v === "") continue;
|
|
97
|
-
const field = k.slice(filterPrefix.length);
|
|
98
|
-
if (field === "") continue;
|
|
99
|
-
// ponytail: comma-join — Facet-Werte sind select-Options/booleans
|
|
100
|
-
// ohne Komma; upgrade auf repeated-keys falls je Komma-Werte nötig.
|
|
101
|
-
out[field] = v.split(",").filter((s) => s !== "");
|
|
102
|
-
}
|
|
103
|
-
return out;
|
|
104
|
-
}, [params, filterPrefix]);
|
|
105
|
-
|
|
106
|
-
const setSort = useCallback(
|
|
107
|
-
(next: ListSort | null) => {
|
|
108
|
-
// Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
|
|
109
|
-
// Page (sonst wäre der User auf "Seite 5 von alter Sortierung"
|
|
110
|
-
// hängen, was visuell verwirrend ist). Page-Reset gilt auch bei
|
|
111
|
-
// null — gleiche Logik.
|
|
112
|
-
nav.setSearchParams({
|
|
113
|
-
[key(screenId, "sort")]: next === null ? null : next.field,
|
|
114
|
-
[key(screenId, "dir")]: next === null ? null : next.dir,
|
|
115
|
-
[key(screenId, "page")]: null,
|
|
116
|
-
});
|
|
117
|
-
},
|
|
118
|
-
[nav, screenId],
|
|
119
|
-
);
|
|
120
|
-
|
|
121
|
-
const setQ = useCallback(
|
|
122
|
-
(next: string) => {
|
|
123
|
-
// Search-Change resettet Page (gleicher Grund wie Sort) UND
|
|
124
|
-
// Sort? Nein — User kann mit aktivem Sort suchen wollen, das
|
|
125
|
-
// soll die Sortierung nicht zerlegen. Nur Page wird gereset.
|
|
126
|
-
nav.setSearchParams({
|
|
127
|
-
[key(screenId, "q")]: next === "" ? null : next,
|
|
128
|
-
[key(screenId, "page")]: null,
|
|
129
|
-
});
|
|
130
|
-
},
|
|
131
|
-
[nav, screenId],
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
const setPage = useCallback(
|
|
135
|
-
(next: number) => {
|
|
136
|
-
nav.setSearchParams({
|
|
137
|
-
[key(screenId, "page")]: next <= 1 ? null : String(next),
|
|
138
|
-
});
|
|
139
|
-
},
|
|
140
|
-
[nav, screenId],
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
const setFilter = useCallback(
|
|
144
|
-
(field: string, values: readonly string[]) => {
|
|
145
|
-
nav.setSearchParams({
|
|
146
|
-
[key(screenId, `f.${field}`)]: values.length === 0 ? null : values.join(","),
|
|
147
|
-
[key(screenId, "page")]: null,
|
|
148
|
-
});
|
|
149
|
-
},
|
|
150
|
-
[nav, screenId],
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
const setDateRange = useCallback(
|
|
154
|
-
(field: string, next: { readonly from: string; readonly to: string }) => {
|
|
155
|
-
nav.setSearchParams({
|
|
156
|
-
[key(screenId, `f.${field}.from`)]: next.from === "" ? null : next.from,
|
|
157
|
-
[key(screenId, `f.${field}.to`)]: next.to === "" ? null : next.to,
|
|
158
|
-
[key(screenId, "page")]: null,
|
|
159
|
-
});
|
|
160
|
-
},
|
|
161
|
-
[nav, screenId],
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
const clearFilters = useCallback(() => {
|
|
165
|
-
const updates: Record<string, string | null> = { [key(screenId, "page")]: null };
|
|
166
|
-
for (const field of Object.keys(filters)) updates[key(screenId, `f.${field}`)] = null;
|
|
167
|
-
nav.setSearchParams(updates);
|
|
168
|
-
}, [nav, screenId, filters]);
|
|
169
|
-
|
|
170
|
-
return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters, setDateRange };
|
|
171
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import type { DispatcherError, WriteResult } from "@cosmicdrift/kumiko-headless";
|
|
2
|
-
import { useCallback, useRef, useState } from "react";
|
|
3
|
-
import { useDispatcher } from "../context/dispatcher-context";
|
|
4
|
-
|
|
5
|
-
// React wrapper around dispatcher.write — the write-side sibling of
|
|
6
|
-
// useQuery. One hook instance per handler-type; `mutate` carries the
|
|
7
|
-
// payload so a single instance serves list-row actions with varying
|
|
8
|
-
// payloads.
|
|
9
|
-
//
|
|
10
|
-
// `mutate` resolves with the raw WriteResult so callers can branch
|
|
11
|
-
// (navigate on success, keep the form open on failure) without waiting
|
|
12
|
-
// for a re-render of `error`/`data`.
|
|
13
|
-
|
|
14
|
-
export type UseMutationResult<TData> = {
|
|
15
|
-
readonly mutate: (payload: unknown) => Promise<WriteResult<TData>>;
|
|
16
|
-
readonly pending: boolean;
|
|
17
|
-
readonly error: DispatcherError | null;
|
|
18
|
-
readonly data: TData | null;
|
|
19
|
-
readonly reset: () => void;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export function useMutation<TData = unknown>(type: string): UseMutationResult<TData> {
|
|
23
|
-
const dispatcher = useDispatcher();
|
|
24
|
-
const [pending, setPending] = useState(false);
|
|
25
|
-
const [error, setError] = useState<DispatcherError | null>(null);
|
|
26
|
-
const [data, setData] = useState<TData | null>(null);
|
|
27
|
-
// Sequence guard: `mutate` has no abort (unlike useQuery's GETs, a write
|
|
28
|
-
// already landed server-side and can't be cancelled). Two overlapping
|
|
29
|
-
// calls on one instance (e.g. two list-row actions) must not let the
|
|
30
|
-
// first-to-resolve clobber pending/error/data set by the second — only
|
|
31
|
-
// the most recently STARTED call's outcome may update shared state.
|
|
32
|
-
const sequence = useRef(0);
|
|
33
|
-
|
|
34
|
-
const mutate = useCallback(
|
|
35
|
-
async (payload: unknown): Promise<WriteResult<TData>> => {
|
|
36
|
-
const callSeq = ++sequence.current;
|
|
37
|
-
setPending(true);
|
|
38
|
-
setError(null);
|
|
39
|
-
const result = await dispatcher.write<TData>(type, payload);
|
|
40
|
-
if (callSeq === sequence.current) {
|
|
41
|
-
if (result.isSuccess) {
|
|
42
|
-
setData(result.data);
|
|
43
|
-
} else {
|
|
44
|
-
setError(result.error);
|
|
45
|
-
}
|
|
46
|
-
setPending(false);
|
|
47
|
-
}
|
|
48
|
-
return result;
|
|
49
|
-
},
|
|
50
|
-
[dispatcher, type],
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const reset = useCallback(() => {
|
|
54
|
-
sequence.current++; // invalidate any in-flight mutate's late update
|
|
55
|
-
setPending(false);
|
|
56
|
-
setError(null);
|
|
57
|
-
setData(null);
|
|
58
|
-
}, []);
|
|
59
|
-
|
|
60
|
-
return { mutate, pending, error, data, reset };
|
|
61
|
-
}
|
package/src/hooks/use-query.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import type { DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import { useDispatcher } from "../context/dispatcher-context";
|
|
4
|
-
import { useLiveEvents } from "../sse/live-events";
|
|
5
|
-
|
|
6
|
-
// React wrapper around dispatcher.query. Fires on mount, re-fires
|
|
7
|
-
// whenever `type` or the serialized payload change, and exposes a
|
|
8
|
-
// `refetch` imperative hook for after-mutation reloads.
|
|
9
|
-
//
|
|
10
|
-
// Cancellation: each fetch owns an AbortController; a newer fetch
|
|
11
|
-
// (change in deps or manual refetch) aborts the older one's network
|
|
12
|
-
// call before firing, so a slow request can't overwrite a fresh one.
|
|
13
|
-
// Unmount aborts any in-flight call too — React will throw the
|
|
14
|
-
// "state on unmounted" warning if we set state after unmount, and
|
|
15
|
-
// aborting pre-emptively makes that path a no-op.
|
|
16
|
-
//
|
|
17
|
-
// Result shape modeled on the dispatcher's own QueryResult: a
|
|
18
|
-
// discriminated state so callers can branch on `loading | data | error`
|
|
19
|
-
// without undefined-juggling. The first render is `loading: true,
|
|
20
|
-
// data: null, error: null` (no optimistic "online" cache — add that
|
|
21
|
-
// later if needed).
|
|
22
|
-
|
|
23
|
-
export type UseQueryResult<TData> = {
|
|
24
|
-
readonly data: TData | null;
|
|
25
|
-
readonly error: DispatcherError | null;
|
|
26
|
-
readonly loading: boolean;
|
|
27
|
-
readonly refetch: () => Promise<void>;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export type UseQueryOptions = {
|
|
31
|
-
// When false, skips the automatic fetch-on-mount and re-fetch on
|
|
32
|
-
// dep-change. Useful for lazy queries that only run after a user
|
|
33
|
-
// action. `refetch()` still works as an imperative trigger.
|
|
34
|
-
readonly enabled?: boolean;
|
|
35
|
-
// When true, subscribe to SSE events for the entity this query
|
|
36
|
-
// targets and refetch on any create/update/delete/restore event.
|
|
37
|
-
// The entity-name is parsed from the query type using Kumiko's
|
|
38
|
-
// `<feature>:query:<entity>:<verb>` convention — if the type
|
|
39
|
-
// doesn't follow that shape, live-mode is a no-op.
|
|
40
|
-
readonly live?: boolean;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
// Extract the entity-name from a standard Kumiko query type. Returns
|
|
44
|
-
// undefined for non-conforming types so the live-mode silently skips
|
|
45
|
-
// them instead of subscribing to a channel no event will ever match.
|
|
46
|
-
export function entityFromQueryType(type: string): string | undefined {
|
|
47
|
-
// Expected shape: "<feature>:query:<entity>:<verb>"
|
|
48
|
-
const parts = type.split(":");
|
|
49
|
-
if (parts.length !== 4) return undefined;
|
|
50
|
-
if (parts[1] !== "query") return undefined;
|
|
51
|
-
return parts[2];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function useQuery<TData = unknown>(
|
|
55
|
-
type: string,
|
|
56
|
-
payload: unknown,
|
|
57
|
-
options: UseQueryOptions = {},
|
|
58
|
-
): UseQueryResult<TData> {
|
|
59
|
-
const dispatcher = useDispatcher();
|
|
60
|
-
const { enabled = true, live = false } = options;
|
|
61
|
-
|
|
62
|
-
const [data, setData] = useState<TData | null>(null);
|
|
63
|
-
const [error, setError] = useState<DispatcherError | null>(null);
|
|
64
|
-
const [loading, setLoading] = useState<boolean>(enabled);
|
|
65
|
-
|
|
66
|
-
// Track the AbortController for the most recent fetch so a newer
|
|
67
|
-
// call can cancel the older one. Ref, not state, because the
|
|
68
|
-
// controller identity shouldn't trigger a re-render.
|
|
69
|
-
const activeCtrl = useRef<AbortController | null>(null);
|
|
70
|
-
|
|
71
|
-
// Serialize payload so object-identity-changes across renders (the
|
|
72
|
-
// caller building a fresh `{}` every render) don't loop. Strings are
|
|
73
|
-
// stable by reference in React's dep-array check.
|
|
74
|
-
const payloadKey = JSON.stringify(payload);
|
|
75
|
-
|
|
76
|
-
// `dispatcher` is stable (context identity); `type` + `payloadKey`
|
|
77
|
-
// are the meaningful re-run triggers. `payload` itself is intentionally
|
|
78
|
-
// not in deps — it's serialized into payloadKey above.
|
|
79
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: payload goes through payloadKey
|
|
80
|
-
const run = useCallback(async (): Promise<void> => {
|
|
81
|
-
// Abort whatever's in flight. observer on Safari 17+ handles this
|
|
82
|
-
// without raising a fetch-throw in the previous caller — they
|
|
83
|
-
// already set the error path.
|
|
84
|
-
activeCtrl.current?.abort();
|
|
85
|
-
const ctrl = new AbortController();
|
|
86
|
-
activeCtrl.current = ctrl;
|
|
87
|
-
|
|
88
|
-
setLoading(true);
|
|
89
|
-
const result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
|
|
90
|
-
// skip: a newer fetch already superseded this one, don't clobber its state
|
|
91
|
-
if (ctrl.signal.aborted) return;
|
|
92
|
-
if (result.isSuccess) {
|
|
93
|
-
setData(result.data);
|
|
94
|
-
setError(null);
|
|
95
|
-
} else {
|
|
96
|
-
// A cancelled request comes back with code "aborted" from the
|
|
97
|
-
// skip: aborted request, a newer run already replaces this result
|
|
98
|
-
if (result.error.code === "aborted") return;
|
|
99
|
-
setError(result.error);
|
|
100
|
-
}
|
|
101
|
-
setLoading(false);
|
|
102
|
-
}, [dispatcher, type, payloadKey]);
|
|
103
|
-
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
if (!enabled) {
|
|
106
|
-
setLoading(false);
|
|
107
|
-
// skip: query disabled, loading state already cleared above
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
void run();
|
|
111
|
-
return () => {
|
|
112
|
-
activeCtrl.current?.abort();
|
|
113
|
-
};
|
|
114
|
-
}, [enabled, run]);
|
|
115
|
-
|
|
116
|
-
// Live-mode: auf SSE-Events für die Query-Entity hören und refetchen.
|
|
117
|
-
// Separater Effect, damit eine Änderung an `live` oder `type` das
|
|
118
|
-
// Subscription-Lifecycle genau einmal durchwalzt.
|
|
119
|
-
const subscribeLive = useLiveEvents();
|
|
120
|
-
useEffect(() => {
|
|
121
|
-
// skip: live mode or query disabled, no SSE subscription needed
|
|
122
|
-
if (!live || !enabled) return;
|
|
123
|
-
const entity = entityFromQueryType(type);
|
|
124
|
-
// skip: query type has no mapped entity, nothing to subscribe to
|
|
125
|
-
if (entity === undefined) return;
|
|
126
|
-
return subscribeLive(entity, () => {
|
|
127
|
-
void run();
|
|
128
|
-
});
|
|
129
|
-
}, [live, enabled, type, run, subscribeLive]);
|
|
130
|
-
|
|
131
|
-
return { data, error, loading, refetch: run };
|
|
132
|
-
}
|