@cosmicdrift/kumiko-renderer-web 0.79.2 → 0.80.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 +4 -4
- package/src/__tests__/download.test.ts +52 -0
- package/src/index.ts +1 -0
- package/src/lib/download.ts +29 -0
- package/src/primitives/index.tsx +52 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.80.0",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
"./styles.css": "./src/styles.css"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
19
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.80.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.80.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.80.0",
|
|
22
22
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
23
23
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
24
24
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { postWithDownload } from "../lib/download";
|
|
4
|
+
|
|
5
|
+
function dispatcherReturning(result: unknown): Dispatcher {
|
|
6
|
+
return { query: async () => result } as unknown as Dispatcher; // @cast-boundary test-stub
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("postWithDownload", () => {
|
|
10
|
+
test("success: navigates to the returned signed URL, returns null", async () => {
|
|
11
|
+
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
12
|
+
const url = "https://store.example/export.zip?sig=abc";
|
|
13
|
+
const err = await postWithDownload(
|
|
14
|
+
dispatcherReturning({ isSuccess: true, data: { url } }),
|
|
15
|
+
"f:query:download-by-job",
|
|
16
|
+
{ jobId: "j1" },
|
|
17
|
+
);
|
|
18
|
+
expect(err).toBeNull();
|
|
19
|
+
expect(assign).toHaveBeenCalledWith(url);
|
|
20
|
+
assign.mockRestore();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("query failure: returns the dispatcher error, no navigation", async () => {
|
|
24
|
+
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
25
|
+
const error: DispatcherError = {
|
|
26
|
+
code: "not_found",
|
|
27
|
+
httpStatus: 404,
|
|
28
|
+
i18nKey: "userDataRights.errors.download.notFound",
|
|
29
|
+
message: "nope",
|
|
30
|
+
};
|
|
31
|
+
const err = await postWithDownload(
|
|
32
|
+
dispatcherReturning({ isSuccess: false, error }),
|
|
33
|
+
"f:query:download-by-job",
|
|
34
|
+
{ jobId: "j1" },
|
|
35
|
+
);
|
|
36
|
+
expect(err).toEqual(error);
|
|
37
|
+
expect(assign).not.toHaveBeenCalled();
|
|
38
|
+
assign.mockRestore();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("success but no url: returns synthetic error, no navigation", async () => {
|
|
42
|
+
const assign = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
43
|
+
const err = await postWithDownload(
|
|
44
|
+
dispatcherReturning({ isSuccess: true, data: {} }),
|
|
45
|
+
"f:query:download-by-job",
|
|
46
|
+
{ jobId: "j1" },
|
|
47
|
+
);
|
|
48
|
+
expect(err?.code).toBe("download_url_missing");
|
|
49
|
+
expect(assign).not.toHaveBeenCalled();
|
|
50
|
+
assign.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -122,6 +122,7 @@ export { filterByAccess, resolveDefaultId, WorkspaceShell } from "./layout/works
|
|
|
122
122
|
export type { WorkspaceSwitcherProps } from "./layout/workspace-switcher";
|
|
123
123
|
export { WorkspaceSwitcher } from "./layout/workspace-switcher";
|
|
124
124
|
export { cn } from "./lib/cn";
|
|
125
|
+
export { postWithDownload } from "./lib/download";
|
|
125
126
|
export { BareFormProvider, defaultPrimitives } from "./primitives";
|
|
126
127
|
export type { ActionMenuProps, MenuItemDef } from "./primitives/action-menu";
|
|
127
128
|
export { ActionMenu } from "./primitives/action-menu";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Dispatcher, DispatcherError } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
|
|
3
|
+
// Dispatch a query/handler whose result carries `{ url }` (a signed,
|
|
4
|
+
// short-lived storage URL with `content-disposition: attachment`), then
|
|
5
|
+
// navigate the browser to it so the file downloads. The query rides the
|
|
6
|
+
// live dispatcher, so it carries the `X-CSRF-Token` header automatically —
|
|
7
|
+
// unlike a plain `<a href>` navigation, which sends only cookies and trips
|
|
8
|
+
// the CSRF double-submit check on any server-side re-dispatch.
|
|
9
|
+
//
|
|
10
|
+
// Web-only (uses `window`). Returns the dispatcher error on failure (so the
|
|
11
|
+
// caller can surface its i18nKey), or null on success.
|
|
12
|
+
export async function postWithDownload(
|
|
13
|
+
dispatcher: Dispatcher,
|
|
14
|
+
type: string,
|
|
15
|
+
payload: unknown,
|
|
16
|
+
): Promise<DispatcherError | null> {
|
|
17
|
+
const res = await dispatcher.query<{ url?: string }>(type, payload);
|
|
18
|
+
if (!res.isSuccess) return res.error;
|
|
19
|
+
if (!res.data?.url) {
|
|
20
|
+
return {
|
|
21
|
+
code: "download_url_missing",
|
|
22
|
+
httpStatus: 502,
|
|
23
|
+
i18nKey: "errors.download.urlMissing",
|
|
24
|
+
message: "download query returned no url",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
window.location.assign(res.data.url);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/primitives/index.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
import {
|
|
20
20
|
type BannerProps,
|
|
21
21
|
type ButtonProps,
|
|
22
|
+
type CardProps,
|
|
22
23
|
type CorePrimitives,
|
|
23
24
|
type DataTableFacet,
|
|
24
25
|
type DataTableProps,
|
|
@@ -1538,6 +1539,56 @@ function DefaultHeading({ variant = "page", children, testId }: HeadingProps): R
|
|
|
1538
1539
|
import { ConfigCascadeView as DefaultConfigCascadeView } from "../components/config-cascade";
|
|
1539
1540
|
import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/config-source-badge";
|
|
1540
1541
|
|
|
1542
|
+
// Generische Card-Chrome (rounded-xl wie die Entity-Card) — slot- + options-
|
|
1543
|
+
// basiert, damit der Contract additiv wächst und Consumer nie migriert werden.
|
|
1544
|
+
function DefaultCard({ slots, options, className, testId, children }: CardProps): ReactNode {
|
|
1545
|
+
const padded = options?.padded ?? true;
|
|
1546
|
+
const radius = options?.radius ?? "xl";
|
|
1547
|
+
const footerBordered = options?.footerBordered ?? true;
|
|
1548
|
+
const s = slots ?? {};
|
|
1549
|
+
const defaultHeader =
|
|
1550
|
+
s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined ? (
|
|
1551
|
+
<div className="flex flex-wrap items-start justify-between gap-3 px-6 pt-6 pb-4">
|
|
1552
|
+
<div className="flex flex-col gap-1">
|
|
1553
|
+
{s.title !== undefined && (
|
|
1554
|
+
<h3 className="text-base font-semibold leading-none tracking-tight">{s.title}</h3>
|
|
1555
|
+
)}
|
|
1556
|
+
{s.subtitle !== undefined && (
|
|
1557
|
+
<p className="text-sm text-muted-foreground">{s.subtitle}</p>
|
|
1558
|
+
)}
|
|
1559
|
+
</div>
|
|
1560
|
+
{s.headerActions}
|
|
1561
|
+
</div>
|
|
1562
|
+
) : null;
|
|
1563
|
+
const header = s.header ?? defaultHeader;
|
|
1564
|
+
const hasHeader = header !== null && header !== undefined;
|
|
1565
|
+
return (
|
|
1566
|
+
<div
|
|
1567
|
+
data-testid={testId}
|
|
1568
|
+
className={cn(
|
|
1569
|
+
"flex flex-col overflow-hidden border bg-card text-card-foreground shadow-sm",
|
|
1570
|
+
radius === "xl" ? "rounded-xl" : "rounded-lg",
|
|
1571
|
+
className,
|
|
1572
|
+
)}
|
|
1573
|
+
>
|
|
1574
|
+
{header}
|
|
1575
|
+
{children !== undefined && (
|
|
1576
|
+
<div className={cn("grow", padded && (hasHeader ? "px-6 pb-6" : "p-6"))}>{children}</div>
|
|
1577
|
+
)}
|
|
1578
|
+
{s.footer !== undefined && (
|
|
1579
|
+
<div
|
|
1580
|
+
className={cn(
|
|
1581
|
+
"flex items-center justify-end gap-2 px-6 py-4",
|
|
1582
|
+
footerBordered && "border-t bg-muted/30",
|
|
1583
|
+
)}
|
|
1584
|
+
>
|
|
1585
|
+
{s.footer}
|
|
1586
|
+
</div>
|
|
1587
|
+
)}
|
|
1588
|
+
</div>
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1541
1592
|
export const defaultPrimitives: CorePrimitives = {
|
|
1542
1593
|
Button: DefaultButton,
|
|
1543
1594
|
Banner: DefaultBanner,
|
|
@@ -1546,6 +1597,7 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
1546
1597
|
DataTable: DefaultDataTable,
|
|
1547
1598
|
Form: DefaultForm,
|
|
1548
1599
|
Section: DefaultSection,
|
|
1600
|
+
Card: DefaultCard,
|
|
1549
1601
|
Grid: DefaultGrid,
|
|
1550
1602
|
GridCell: DefaultGridCell,
|
|
1551
1603
|
Text: DefaultText,
|