@cosmicdrift/kumiko-renderer 1.0.0 → 2.0.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 -4
- package/src/__tests__/error-i18n-defaults.test.ts +13 -0
- package/src/__tests__/format-when.test.ts +12 -0
- package/src/__tests__/i18n.test.tsx +59 -0
- package/src/__tests__/qn.test.ts +44 -1
- package/src/__tests__/sort-by-accessor.test.ts +48 -0
- package/src/app/__tests__/config-edit-shim.test.ts +40 -0
- package/src/app/__tests__/screen-access-allows.test.ts +24 -0
- package/src/app/dashboard-body.tsx +32 -0
- package/src/app/extension-sections.tsx +11 -4
- package/src/app/kumiko-screen.tsx +408 -20
- package/src/app/projection-detail-shim.ts +72 -0
- package/src/app/projection-list-shim.ts +62 -0
- package/src/app/qn.ts +13 -0
- package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
- package/src/components/render-edit-logic.ts +8 -5
- package/src/components/render-edit.tsx +25 -2
- package/src/components/render-field.tsx +2 -1
- package/src/components/render-list.tsx +24 -2
- package/src/context/user-roles-context.tsx +27 -0
- package/src/format-when.ts +11 -0
- package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
- package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
- package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
- package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
- package/src/hooks/use-ai-text.ts +172 -0
- package/src/hooks/use-disclosure.ts +20 -0
- package/src/hooks/use-mutation.ts +61 -0
- package/src/hooks/use-query.ts +5 -2
- package/src/hooks/use-reference-lookup.ts +2 -1
- package/src/hooks/use-stream-handler.ts +134 -0
- package/src/i18n-defaults.ts +56 -0
- package/src/i18n.tsx +72 -31
- package/src/index.ts +31 -0
- package/src/primitives.tsx +70 -4
- package/src/sort-by-accessor.ts +20 -0
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
CHANGED
|
@@ -87,14 +87,14 @@ export function useQuery<TData = unknown>(
|
|
|
87
87
|
|
|
88
88
|
setLoading(true);
|
|
89
89
|
const result = await dispatcher.query<TData>(type, payload, { signal: ctrl.signal });
|
|
90
|
-
//
|
|
90
|
+
// skip: a newer fetch already superseded this one, don't clobber its state
|
|
91
91
|
if (ctrl.signal.aborted) return;
|
|
92
92
|
if (result.isSuccess) {
|
|
93
93
|
setData(result.data);
|
|
94
94
|
setError(null);
|
|
95
95
|
} else {
|
|
96
96
|
// A cancelled request comes back with code "aborted" from the
|
|
97
|
-
//
|
|
97
|
+
// skip: aborted request, a newer run already replaces this result
|
|
98
98
|
if (result.error.code === "aborted") return;
|
|
99
99
|
setError(result.error);
|
|
100
100
|
}
|
|
@@ -104,6 +104,7 @@ export function useQuery<TData = unknown>(
|
|
|
104
104
|
useEffect(() => {
|
|
105
105
|
if (!enabled) {
|
|
106
106
|
setLoading(false);
|
|
107
|
+
// skip: query disabled, loading state already cleared above
|
|
107
108
|
return;
|
|
108
109
|
}
|
|
109
110
|
void run();
|
|
@@ -117,8 +118,10 @@ export function useQuery<TData = unknown>(
|
|
|
117
118
|
// Subscription-Lifecycle genau einmal durchwalzt.
|
|
118
119
|
const subscribeLive = useLiveEvents();
|
|
119
120
|
useEffect(() => {
|
|
121
|
+
// skip: live mode or query disabled, no SSE subscription needed
|
|
120
122
|
if (!live || !enabled) return;
|
|
121
123
|
const entity = entityFromQueryType(type);
|
|
124
|
+
// skip: query type has no mapped entity, nothing to subscribe to
|
|
122
125
|
if (entity === undefined) return;
|
|
123
126
|
return subscribeLive(entity, () => {
|
|
124
127
|
void run();
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// Entity, Live-Updates kommen via SSE (use-query-live).
|
|
19
19
|
|
|
20
20
|
import { useMemo } from "react";
|
|
21
|
+
import { toKebab } from "../app/qn";
|
|
21
22
|
import { REFERENCE_LIST_LOOKUP_LIMIT } from "./reference-limits";
|
|
22
23
|
import { useQuery } from "./use-query";
|
|
23
24
|
|
|
@@ -35,7 +36,7 @@ export function useReferenceLookup(
|
|
|
35
36
|
refEntity: string,
|
|
36
37
|
labelField: string,
|
|
37
38
|
): { readonly map: ReferenceLookupMap; readonly loading: boolean } {
|
|
38
|
-
const queryQn = `${featureName}:query:${refEntity}:list`;
|
|
39
|
+
const queryQn = `${toKebab(featureName)}:query:${toKebab(refEntity)}:list`;
|
|
39
40
|
const result = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
|
|
40
41
|
limit: REFERENCE_LIST_LOOKUP_LIMIT,
|
|
41
42
|
});
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
// React wrapper around dispatcher.stream (#1382). Accumulates yielded
|
|
6
|
+
// chunks into `chunks` (unlike useQuery({live:true}), which only
|
|
7
|
+
// invalidates and re-fetches). Each start() owns an AbortController;
|
|
8
|
+
// a newer start() / unmount aborts the previous run so late chunks
|
|
9
|
+
// cannot clobber fresher state.
|
|
10
|
+
|
|
11
|
+
export type StreamStatus = "idle" | "streaming" | "done" | "error";
|
|
12
|
+
|
|
13
|
+
export type UseStreamHandlerResult<TChunk> = {
|
|
14
|
+
readonly chunks: readonly TChunk[];
|
|
15
|
+
readonly status: StreamStatus;
|
|
16
|
+
readonly error: DispatcherError | null;
|
|
17
|
+
readonly start: (payload?: unknown) => Promise<void>;
|
|
18
|
+
readonly abort: () => void;
|
|
19
|
+
readonly reset: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type UseStreamHandlerOptions = {
|
|
23
|
+
// When true, start() runs once on mount with the initial payload.
|
|
24
|
+
// Default false — streams are usually user-triggered (unlike queries).
|
|
25
|
+
readonly autoStart?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function useStreamHandler<TChunk = unknown>(
|
|
29
|
+
type: string,
|
|
30
|
+
payload: unknown = {},
|
|
31
|
+
options: UseStreamHandlerOptions = {},
|
|
32
|
+
): UseStreamHandlerResult<TChunk> {
|
|
33
|
+
const dispatcher = useDispatcher();
|
|
34
|
+
const { autoStart = false } = options;
|
|
35
|
+
|
|
36
|
+
const [chunks, setChunks] = useState<readonly TChunk[]>([]);
|
|
37
|
+
const [status, setStatus] = useState<StreamStatus>("idle");
|
|
38
|
+
const [error, setError] = useState<DispatcherError | null>(null);
|
|
39
|
+
|
|
40
|
+
const activeCtrl = useRef<AbortController | null>(null);
|
|
41
|
+
const payloadRef = useRef(payload);
|
|
42
|
+
payloadRef.current = payload;
|
|
43
|
+
|
|
44
|
+
const abort = useCallback((): void => {
|
|
45
|
+
activeCtrl.current?.abort();
|
|
46
|
+
activeCtrl.current = null;
|
|
47
|
+
// User cancel — exit streaming so UI can re-enable the start button.
|
|
48
|
+
setStatus((s) => (s === "streaming" ? "idle" : s));
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const reset = useCallback((): void => {
|
|
52
|
+
abort();
|
|
53
|
+
setChunks([]);
|
|
54
|
+
setStatus("idle");
|
|
55
|
+
setError(null);
|
|
56
|
+
}, [abort]);
|
|
57
|
+
|
|
58
|
+
// Shared by every abort-detection branch below: only touch status if this
|
|
59
|
+
// run is still the active one (or activeCtrl was already cleared by abort()).
|
|
60
|
+
const settleAborted = useCallback((ctrl: AbortController): void => {
|
|
61
|
+
if (activeCtrl.current === null || activeCtrl.current === ctrl) setStatus("idle");
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const start = useCallback(
|
|
65
|
+
async (overridePayload?: unknown): Promise<void> => {
|
|
66
|
+
activeCtrl.current?.abort();
|
|
67
|
+
const ctrl = new AbortController();
|
|
68
|
+
activeCtrl.current = ctrl;
|
|
69
|
+
|
|
70
|
+
setChunks([]);
|
|
71
|
+
setError(null);
|
|
72
|
+
setStatus("streaming");
|
|
73
|
+
|
|
74
|
+
const body = overridePayload !== undefined ? overridePayload : payloadRef.current;
|
|
75
|
+
try {
|
|
76
|
+
for await (const chunk of dispatcher.stream<TChunk>(type, body, { signal: ctrl.signal })) {
|
|
77
|
+
// skip: a newer start() already superseded this run
|
|
78
|
+
if (ctrl.signal.aborted) {
|
|
79
|
+
settleAborted(ctrl);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
setChunks((prev) => [...prev, chunk]);
|
|
83
|
+
}
|
|
84
|
+
// skip: aborted after last chunk, don't mark done
|
|
85
|
+
if (ctrl.signal.aborted) {
|
|
86
|
+
settleAborted(ctrl);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setStatus("done");
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// skip: abort is not an error toast
|
|
92
|
+
if (ctrl.signal.aborted) {
|
|
93
|
+
settleAborted(ctrl);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const mapped = asDispatcherError(e);
|
|
97
|
+
// skip: dispatcher-mapped abort (fetch cancelled)
|
|
98
|
+
if (mapped.code === "aborted") {
|
|
99
|
+
settleAborted(ctrl);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setError(mapped);
|
|
103
|
+
setStatus("error");
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
[dispatcher, type, settleAborted],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
// skip: autoStart off — streams are usually user-triggered
|
|
111
|
+
if (!autoStart) return;
|
|
112
|
+
void start();
|
|
113
|
+
}, [autoStart, start]);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
return () => {
|
|
117
|
+
activeCtrl.current?.abort();
|
|
118
|
+
};
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
return { chunks, status, error, start, abort, reset };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function asDispatcherError(e: unknown): DispatcherError {
|
|
125
|
+
if (e && typeof e === "object" && "code" in e && "message" in e && "i18nKey" in e) {
|
|
126
|
+
return e as DispatcherError;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
code: "stream_error",
|
|
130
|
+
httpStatus: 0,
|
|
131
|
+
i18nKey: "errors.unknown",
|
|
132
|
+
message: e instanceof Error ? e.message : String(e),
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
20
20
|
"kumiko.actions.reload": "Neu laden",
|
|
21
21
|
"kumiko.actions.create": "Neu",
|
|
22
22
|
"kumiko.actions.edit": "Bearbeiten",
|
|
23
|
+
"kumiko.actions.copyLink": "Link kopieren",
|
|
24
|
+
"kumiko.actions.copyLinkCopied": "Kopiert!",
|
|
23
25
|
|
|
24
26
|
// Version — Update-Awareness-Banner (UpdateChecker).
|
|
25
27
|
"kumiko.version.update-available": "Eine neue Version ist verfügbar.",
|
|
@@ -43,17 +45,40 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
43
45
|
"kumiko.combobox.search-placeholder": "Suchen…",
|
|
44
46
|
"kumiko.combobox.empty": "Keine Treffer.",
|
|
45
47
|
"kumiko.combobox.loading": "Lade…",
|
|
48
|
+
|
|
49
|
+
// Dashboard — Default-Label für den "(alle)"-Eintrag im Screen-Filter,
|
|
50
|
+
// wenn DashboardFilterDefinition.allLabel nicht gesetzt ist.
|
|
51
|
+
"kumiko.dashboard.filter.all": "Alle",
|
|
46
52
|
"kumiko.combobox.placeholder": "—",
|
|
47
53
|
|
|
54
|
+
// Widgets — Query-States (QueryTable, LoadingState, ErrorState).
|
|
55
|
+
"kumiko.widget.loading": "Lade…",
|
|
56
|
+
"kumiko.widget.error.title": "Konnte nicht geladen werden.",
|
|
57
|
+
|
|
48
58
|
// Nav — Sidebar Tree (Toggle-aria-Labels).
|
|
49
59
|
"kumiko.nav.expand": "Aufklappen",
|
|
50
60
|
"kumiko.nav.collapse": "Zuklappen",
|
|
61
|
+
"kumiko.nav.search": "Navigation durchsuchen…",
|
|
51
62
|
|
|
52
63
|
// Dialog — Confirm-Buttons + Close-aria-Label.
|
|
53
64
|
"kumiko.dialog.confirm": "Bestätigen",
|
|
54
65
|
"kumiko.dialog.cancel": "Abbrechen",
|
|
55
66
|
"kumiko.dialog.close": "Schließen",
|
|
56
67
|
|
|
68
|
+
// AiTextField/AiTextArea — Ghost-Text-Hint, Toolbar-Aria-Labels, Diff-Dialog.
|
|
69
|
+
"kumiko.aiText.acceptHint": "Tab = übernehmen, Esc = verwerfen",
|
|
70
|
+
"kumiko.aiText.correct": "Korrigieren",
|
|
71
|
+
"kumiko.aiText.translate": "Übersetzen",
|
|
72
|
+
"kumiko.aiText.rewrite": "Umschreiben",
|
|
73
|
+
"kumiko.aiText.diff.before": "Vorher",
|
|
74
|
+
"kumiko.aiText.diff.after": "Nachher",
|
|
75
|
+
"kumiko.aiText.diff.generating": "Wird generiert…",
|
|
76
|
+
"kumiko.aiText.style.formal": "Formell",
|
|
77
|
+
"kumiko.aiText.style.casual": "Locker",
|
|
78
|
+
"kumiko.aiText.style.concise": "Kompakt",
|
|
79
|
+
"kumiko.aiText.style.expand": "Ausführlicher",
|
|
80
|
+
"kumiko.aiText.capExceeded": "Monatliches AI-Limit erreicht.",
|
|
81
|
+
|
|
57
82
|
// Row-Actions — Fehler-Toast wenn ein Action-Write fehlschlägt.
|
|
58
83
|
"kumiko.rowAction.failed": "Aktion fehlgeschlagen",
|
|
59
84
|
|
|
@@ -119,7 +144,13 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
119
144
|
"errors.unconfigured": "Diese Funktion ist noch nicht konfiguriert.",
|
|
120
145
|
"errors.internal": "Etwas ist schiefgegangen. Bitte versuche es später erneut.",
|
|
121
146
|
"errors.rate_limited": "Zu viele Anfragen. Bitte versuche es in Kürze erneut.",
|
|
147
|
+
"errors.cap.exceeded":
|
|
148
|
+
"Limit erreicht. Bitte Tarif upgraden oder auf die nächste Periode warten.",
|
|
122
149
|
"errors.download.urlMissing": "Download nicht verfügbar — bitte versuche es erneut.",
|
|
150
|
+
"auth.errors.originNotAllowed": "Zugriff von dieser Herkunft ist nicht erlaubt.",
|
|
151
|
+
"dispatcher.errors.network":
|
|
152
|
+
"Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.",
|
|
153
|
+
"dispatcher.errors.aborted": "Anfrage abgebrochen.",
|
|
123
154
|
},
|
|
124
155
|
en: {
|
|
125
156
|
"kumiko.actions.save": "Save",
|
|
@@ -129,6 +160,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
129
160
|
"kumiko.actions.reload": "Reload",
|
|
130
161
|
"kumiko.actions.create": "New",
|
|
131
162
|
"kumiko.actions.edit": "Edit",
|
|
163
|
+
"kumiko.actions.copyLink": "Copy link",
|
|
164
|
+
"kumiko.actions.copyLinkCopied": "Copied!",
|
|
132
165
|
|
|
133
166
|
"kumiko.version.update-available": "A new version is available.",
|
|
134
167
|
|
|
@@ -149,13 +182,32 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
149
182
|
"kumiko.combobox.loading": "Loading…",
|
|
150
183
|
"kumiko.combobox.placeholder": "—",
|
|
151
184
|
|
|
185
|
+
"kumiko.dashboard.filter.all": "All",
|
|
186
|
+
|
|
187
|
+
"kumiko.widget.loading": "Loading…",
|
|
188
|
+
"kumiko.widget.error.title": "Couldn't load.",
|
|
189
|
+
|
|
152
190
|
"kumiko.nav.expand": "Expand",
|
|
153
191
|
"kumiko.nav.collapse": "Collapse",
|
|
192
|
+
"kumiko.nav.search": "Search navigation…",
|
|
154
193
|
|
|
155
194
|
"kumiko.dialog.confirm": "Confirm",
|
|
156
195
|
"kumiko.dialog.cancel": "Cancel",
|
|
157
196
|
"kumiko.dialog.close": "Close",
|
|
158
197
|
|
|
198
|
+
"kumiko.aiText.acceptHint": "Tab = accept, Esc = discard",
|
|
199
|
+
"kumiko.aiText.correct": "Correct",
|
|
200
|
+
"kumiko.aiText.translate": "Translate",
|
|
201
|
+
"kumiko.aiText.rewrite": "Rewrite",
|
|
202
|
+
"kumiko.aiText.diff.before": "Before",
|
|
203
|
+
"kumiko.aiText.diff.after": "After",
|
|
204
|
+
"kumiko.aiText.diff.generating": "Generating…",
|
|
205
|
+
"kumiko.aiText.style.formal": "Formal",
|
|
206
|
+
"kumiko.aiText.style.casual": "Casual",
|
|
207
|
+
"kumiko.aiText.style.concise": "Concise",
|
|
208
|
+
"kumiko.aiText.style.expand": "Expand",
|
|
209
|
+
"kumiko.aiText.capExceeded": "Monthly AI limit reached.",
|
|
210
|
+
|
|
159
211
|
"kumiko.rowAction.failed": "Action failed",
|
|
160
212
|
|
|
161
213
|
"kumiko.config.source.user": "My value",
|
|
@@ -206,6 +258,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
206
258
|
"errors.unconfigured": "This feature isn't configured yet.",
|
|
207
259
|
"errors.internal": "Something went wrong. Please try again later.",
|
|
208
260
|
"errors.rate_limited": "Too many requests. Please try again shortly.",
|
|
261
|
+
"errors.cap.exceeded": "Limit reached. Upgrade your plan or wait for the next period.",
|
|
209
262
|
"errors.download.urlMissing": "Download unavailable — please try again.",
|
|
263
|
+
"auth.errors.originNotAllowed": "Requests from this origin are not allowed.",
|
|
264
|
+
"dispatcher.errors.network": "Network error. Please check your connection and try again.",
|
|
265
|
+
"dispatcher.errors.aborted": "Request was cancelled.",
|
|
210
266
|
},
|
|
211
267
|
};
|