@cosmicdrift/kumiko-renderer 0.378.0 → 0.379.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/dist/app/action-form-shim.js +1 -2
- package/dist/app/action-form-support.js +16 -4
- package/dist/app/kumiko-screen.js +49 -38
- package/dist/app/list-empty-content.d.ts +1 -0
- package/dist/app/list-empty-content.js +5 -0
- package/dist/app/row-actions.d.ts +7 -1
- package/dist/app/row-actions.js +6 -1
- package/dist/app/secret-mint-body.js +3 -2
- package/dist/components/embedded-list-field.js +16 -7
- package/dist/components/file-list-field.js +4 -0
- package/dist/components/grid-cell-for-field.d.ts +4 -1
- package/dist/components/grid-cell-for-field.js +6 -2
- package/dist/components/list-empty-state.d.ts +4 -1
- package/dist/components/list-empty-state.js +4 -1
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +4 -1
- package/dist/components/render-edit-logic.d.ts +1 -1
- package/dist/components/render-edit-logic.js +8 -2
- package/dist/components/render-edit-types.d.ts +3 -0
- package/dist/components/render-edit.js +25 -6
- package/dist/components/render-field.d.ts +3 -1
- package/dist/components/render-field.js +19 -7
- package/dist/components/render-list.d.ts +3 -1
- package/dist/components/render-list.js +29 -8
- package/dist/components/write-form-section.js +4 -1
- package/dist/field-issue-text.d.ts +4 -0
- package/dist/field-issue-text.js +14 -0
- package/dist/headless/dispatcher/index.d.ts +1 -0
- package/dist/headless/dispatcher/index.js +1 -0
- package/dist/headless/dispatcher/request-id.d.ts +1 -0
- package/dist/headless/dispatcher/request-id.js +15 -0
- package/dist/headless/form/form-controller.js +16 -1
- package/dist/headless/form/zod-bridge.js +7 -0
- package/dist/headless/index.d.ts +2 -1
- package/dist/headless/index.js +2 -1
- package/dist/headless/plural-key.d.ts +1 -0
- package/dist/headless/plural-key.js +5 -0
- package/dist/headless/view-model/edit.d.ts +4 -0
- package/dist/headless/view-model/edit.js +10 -5
- package/dist/headless/view-model/types.d.ts +10 -2
- package/dist/hooks/live-refetch-scheduler.d.ts +6 -0
- package/dist/hooks/live-refetch-scheduler.js +45 -0
- package/dist/hooks/use-query.js +8 -4
- package/dist/hooks/use-reference-lookup.d.ts +1 -0
- package/dist/hooks/use-reference-lookup.js +6 -3
- package/dist/i18n-defaults.js +22 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/leave-guard-dialog.d.ts +2 -0
- package/dist/leave-guard-dialog.js +28 -0
- package/dist/leave-guard.d.ts +15 -0
- package/dist/leave-guard.js +39 -0
- package/dist/live/dispatcher-live.js +1 -16
- package/dist/primitives.d.ts +29 -0
- package/dist/sse/live-events.d.ts +3 -0
- package/dist/sse/live-events.js +3 -0
- package/dist/web/app/create-app.js +2 -2
- package/dist/web/app/dashboard-body.js +13 -1
- package/dist/web/app/nav.js +51 -9
- package/dist/web/layout/nav-tree.js +13 -3
- package/dist/web/layout/shell-header.js +3 -3
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-error-key.d.ts +7 -0
- package/dist/web/lib/upload-error-key.js +23 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +5 -4
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +11 -7
- package/dist/web/primitives/files-dropzone.d.ts +16 -0
- package/dist/web/primitives/files-dropzone.js +52 -0
- package/dist/web/primitives/index.js +62 -29
- package/dist/web/primitives/segmented-select.js +8 -2
- package/dist/web/sse/live-events.d.ts +13 -0
- package/dist/web/sse/live-events.js +88 -23
- package/dist/web/widgets/upload-zone.d.ts +11 -2
- package/dist/web/widgets/upload-zone.js +30 -7
- package/package.json +5 -5
- package/src/changes.json +104 -0
package/dist/web/app/nav.js
CHANGED
|
@@ -11,7 +11,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
11
11
|
// Event-Bus). Hand-rolled Listener-Set ist hier idiomatisch: zwei
|
|
12
12
|
// Notify-Trigger (eigenes pushPath, popstate-Event), nicht generalisierbar.
|
|
13
13
|
import { useCallback, useMemo, useSyncExternalStore, } from "react";
|
|
14
|
-
import { formatPath, parsePath, resolveTarget, useNav, } from "../../index.js";
|
|
14
|
+
import { formatPath, hasUnsavedInput, parsePath, requestLeave, resolveTarget, useNav, } from "../../index.js";
|
|
15
15
|
// basePath erlaubt es, die App unter einem URL-Prefix zu mounten
|
|
16
16
|
// (z.B. `/admin`). Read-Pfad strippt den Prefix vor dem parsePath,
|
|
17
17
|
// Write-Pfad prepend'd ihn vor dem pushState/replaceState/href.
|
|
@@ -54,6 +54,12 @@ function prependBasePath(path, basePath) {
|
|
|
54
54
|
// läuft über einen window-Listener, den wir einmalig verdrahten.
|
|
55
55
|
const listeners = new Set();
|
|
56
56
|
let popstateWired = false;
|
|
57
|
+
// URL the app currently shows; popstate has already moved the browser to the
|
|
58
|
+
// target by the time we can ask, so a declined leave must restore this one.
|
|
59
|
+
let committedUrl = "";
|
|
60
|
+
function readUrl() {
|
|
61
|
+
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
62
|
+
}
|
|
57
63
|
// Stable identity for the omitted-features case — `?? []` would create a
|
|
58
64
|
// fresh array every render, busting the useMemo below on every render for
|
|
59
65
|
// every existing useBrowserNavApi() call site (none of which pass features).
|
|
@@ -64,13 +70,35 @@ function ensurePopstateWired() {
|
|
|
64
70
|
if (typeof window === "undefined")
|
|
65
71
|
return;
|
|
66
72
|
window.addEventListener("popstate", () => {
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
if (!hasUnsavedInput()) {
|
|
74
|
+
committedUrl = readUrl();
|
|
75
|
+
for (const l of listeners)
|
|
76
|
+
l();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const targetUrl = readUrl();
|
|
80
|
+
window.history.pushState(null, "", committedUrl);
|
|
81
|
+
requestLeave(() => {
|
|
82
|
+
// replaceState, not history.back(): the declined entry is already ours
|
|
83
|
+
// and a forward navigation would otherwise land on the wrong entry.
|
|
84
|
+
window.history.replaceState(null, "", targetUrl);
|
|
85
|
+
committedUrl = targetUrl;
|
|
86
|
+
for (const l of listeners)
|
|
87
|
+
l();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
window.addEventListener("beforeunload", (event) => {
|
|
91
|
+
if (!hasUnsavedInput())
|
|
92
|
+
return;
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
event.returnValue = "";
|
|
69
95
|
});
|
|
70
96
|
popstateWired = true;
|
|
71
97
|
}
|
|
72
98
|
function subscribe(listener) {
|
|
73
99
|
ensurePopstateWired();
|
|
100
|
+
if (typeof window !== "undefined" && listeners.size === 0)
|
|
101
|
+
committedUrl = readUrl();
|
|
74
102
|
listeners.add(listener);
|
|
75
103
|
return () => {
|
|
76
104
|
listeners.delete(listener);
|
|
@@ -118,6 +146,7 @@ function applySearchParamUpdates(updates) {
|
|
|
118
146
|
return;
|
|
119
147
|
const url = `${window.location.pathname}${nextSearch}${window.location.hash}`;
|
|
120
148
|
window.history.replaceState(null, "", url);
|
|
149
|
+
committedUrl = readUrl();
|
|
121
150
|
for (const l of listeners)
|
|
122
151
|
l();
|
|
123
152
|
}
|
|
@@ -137,9 +166,12 @@ function pushPath(path, search) {
|
|
|
137
166
|
(search === undefined || window.location.search === search);
|
|
138
167
|
if (unchanged)
|
|
139
168
|
return;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
requestLeave(() => {
|
|
170
|
+
window.history.pushState(null, "", `${path}${search ?? ""}`);
|
|
171
|
+
committedUrl = readUrl();
|
|
172
|
+
for (const l of listeners)
|
|
173
|
+
l();
|
|
174
|
+
});
|
|
143
175
|
}
|
|
144
176
|
function replacePath(path, search) {
|
|
145
177
|
if (typeof window === "undefined")
|
|
@@ -148,9 +180,19 @@ function replacePath(path, search) {
|
|
|
148
180
|
// to avoid creating a history entry, even when the URL is identical.
|
|
149
181
|
// (pushPath skips no-op pushes; replacePath honors the call so the
|
|
150
182
|
// entry-stack semantics stay predictable.)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
183
|
+
const nextUrl = `${path}${search ?? ""}`;
|
|
184
|
+
const leavesCurrentPage = window.location.pathname !== path ||
|
|
185
|
+
(search !== undefined && window.location.search !== search);
|
|
186
|
+
const apply = () => {
|
|
187
|
+
window.history.replaceState(null, "", nextUrl);
|
|
188
|
+
committedUrl = readUrl();
|
|
189
|
+
for (const l of listeners)
|
|
190
|
+
l();
|
|
191
|
+
};
|
|
192
|
+
if (leavesCurrentPage)
|
|
193
|
+
requestLeave(apply);
|
|
194
|
+
else
|
|
195
|
+
apply();
|
|
154
196
|
}
|
|
155
197
|
/** React-Hook der eine NavApi aus der Browser-History baut. Sollte
|
|
156
198
|
* einmal im App-Root aufgerufen und als value an den shared
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
|
3
|
-
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
4
|
import { resolveNavigation } from "../../headless/index.js";
|
|
5
|
+
import { createLiveRefetchScheduler } from "../../hooks/live-refetch-scheduler.js";
|
|
5
6
|
import { lastSegment, toAppSchema, useLiveEvents, useNav, useTranslation } from "../../index.js";
|
|
6
7
|
import { KumikoLink } from "../app/nav.js";
|
|
7
8
|
import { useNavEntities, useNavProviders } from "../app/nav-providers-context.js";
|
|
@@ -205,13 +206,20 @@ function useNavProviderChildren(qn, enabled) {
|
|
|
205
206
|
const [raw, setRaw] = useState(null);
|
|
206
207
|
const [error, setError] = useState(null);
|
|
207
208
|
const [attempt, setAttempt] = useState(0);
|
|
209
|
+
// A live refresh keeps the current nodes on screen instead of blanking them.
|
|
210
|
+
const keepNodesOnRefire = useRef(false);
|
|
208
211
|
useEffect(() => {
|
|
209
212
|
if (!enabled || entities.length === 0)
|
|
210
213
|
return;
|
|
211
|
-
const
|
|
214
|
+
const scheduler = createLiveRefetchScheduler(() => {
|
|
215
|
+
keepNodesOnRefire.current = true;
|
|
216
|
+
setAttempt((n) => n + 1);
|
|
217
|
+
});
|
|
218
|
+
const unsubs = entities.map((e) => subscribeLive(e, scheduler.trigger));
|
|
212
219
|
return () => {
|
|
213
220
|
for (const u of unsubs)
|
|
214
221
|
u();
|
|
222
|
+
scheduler.dispose();
|
|
215
223
|
};
|
|
216
224
|
}, [enabled, entities, subscribeLive]);
|
|
217
225
|
// `attempt` triggert SSE-Refresh + erneutes Subscribe — Biome sieht es
|
|
@@ -228,7 +236,9 @@ function useNavProviderChildren(qn, enabled) {
|
|
|
228
236
|
return;
|
|
229
237
|
}
|
|
230
238
|
setError(null);
|
|
231
|
-
|
|
239
|
+
if (!keepNodesOnRefire.current)
|
|
240
|
+
setRaw(null);
|
|
241
|
+
keepNodesOnRefire.current = false;
|
|
232
242
|
try {
|
|
233
243
|
const subscribe = provider();
|
|
234
244
|
try {
|
|
@@ -61,13 +61,13 @@ export function ShellHeader({ schema, user, headerActions, }) {
|
|
|
61
61
|
: crumbs !== undefined && recordTitle !== undefined && crumbs.length > 0
|
|
62
62
|
? [...crumbs.slice(0, -1), { label: recordTitle }, ...crumbs.slice(-1)]
|
|
63
63
|
: crumbs;
|
|
64
|
-
return (_jsxs("header", { "data-kumiko-layout": "shell-header", className: cn("flex h-14 shrink-0 items-center gap-3 border-b border-border pl-1.5 pr-3 md:pl-4 md:pr-6", compact && "relative"), children: [_jsxs("div", { className:
|
|
64
|
+
return (_jsxs("header", { "data-kumiko-layout": "shell-header", className: cn("flex h-14 shrink-0 items-center gap-3 border-b border-border pl-1.5 pr-3 md:pl-4 md:pr-6", compact && "relative"), children: [_jsxs("div", { className: "@container flex min-w-0 flex-1 items-center gap-3", children: [_jsx(SidebarTrigger, { label: t("kumiko.sidebar.toggle") }), shownCrumbs !== undefined && shownCrumbs.length > 0 && (_jsx(Breadcrumb, { className: "min-w-0", "aria-label": t("kumiko.breadcrumb.label"), children: _jsx(BreadcrumbList, { className: "flex-nowrap gap-1.5 sm:gap-1.5", children: shownCrumbs.map((crumb, index) => {
|
|
65
65
|
const screenId = crumb.screenId;
|
|
66
66
|
const isLast = index === shownCrumbs.length - 1;
|
|
67
|
-
return (_jsxs(Fragment, { children: [index > 0 && (_jsx(BreadcrumbSeparator, { className: "hidden text-muted-foreground
|
|
67
|
+
return (_jsxs(Fragment, { children: [index > 0 && (_jsx(BreadcrumbSeparator, { className: "hidden text-muted-foreground @xs:inline-flex [&>svg]:size-3.5" })), _jsx(BreadcrumbItem, { className: cn(isLast ? "min-w-0" : "hidden shrink-0 @xs:inline-flex"), children: screenId !== undefined && !isLast ? (_jsx(BreadcrumbLink, { href: "#", className: "whitespace-nowrap text-foreground-secondary", onClick: (e) => {
|
|
68
68
|
e.preventDefault();
|
|
69
69
|
nav.navigate({ screenId });
|
|
70
|
-
}, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
|
|
70
|
+
}, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
|
|
71
71
|
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn("flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
72
72
|
}
|
|
73
73
|
// A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
|
|
@@ -3,5 +3,7 @@ export type FileUploadFormDataInput = {
|
|
|
3
3
|
readonly entityType?: string | undefined;
|
|
4
4
|
readonly entityId?: string | undefined;
|
|
5
5
|
readonly fieldName?: string | undefined;
|
|
6
|
+
/** Screen QN of an actionForm: the upload is pending for that form's field, not for an entity. */
|
|
7
|
+
readonly actionForm?: string | undefined;
|
|
6
8
|
};
|
|
7
9
|
export declare function buildFileUploadFormData(input: FileUploadFormDataInput): FormData;
|
|
@@ -7,5 +7,7 @@ export function buildFileUploadFormData(input) {
|
|
|
7
7
|
formData.append("entityId", input.entityId);
|
|
8
8
|
if (input.fieldName !== undefined)
|
|
9
9
|
formData.append("fieldName", input.fieldName);
|
|
10
|
+
if (input.actionForm !== undefined)
|
|
11
|
+
formData.append("actionForm", input.actionForm);
|
|
10
12
|
return formData;
|
|
11
13
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A dropzone refused a file because the field already holds `maxCount` files. */
|
|
2
|
+
export declare class FileCountLimitError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** i18n key for a failed upload. The server's error string starts with a stable code before
|
|
5
|
+
* the first ":"; the text after it is English and never shown, an unknown code gets the
|
|
6
|
+
* generic key. */
|
|
7
|
+
export declare function uploadErrorKey(cause: unknown): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { FileUploadFailedError } from "./upload-file-ref.js";
|
|
2
|
+
/** A dropzone refused a file because the field already holds `maxCount` files. */
|
|
3
|
+
export class FileCountLimitError extends Error {
|
|
4
|
+
}
|
|
5
|
+
const SERVER_CODE_KEYS = {
|
|
6
|
+
file_too_large: "kumiko.widget.upload.too-large",
|
|
7
|
+
request_too_large: "kumiko.widget.upload.too-large",
|
|
8
|
+
invalid_file_type: "kumiko.widget.upload.invalid-type",
|
|
9
|
+
mime_mismatch: "kumiko.widget.upload.invalid-type",
|
|
10
|
+
content_mismatch: "kumiko.widget.upload.invalid-type",
|
|
11
|
+
};
|
|
12
|
+
/** i18n key for a failed upload. The server's error string starts with a stable code before
|
|
13
|
+
* the first ":"; the text after it is English and never shown, an unknown code gets the
|
|
14
|
+
* generic key. */
|
|
15
|
+
export function uploadErrorKey(cause) {
|
|
16
|
+
if (cause instanceof FileCountLimitError)
|
|
17
|
+
return "kumiko.widget.upload.max-count";
|
|
18
|
+
if (cause instanceof FileUploadFailedError && cause.fromServer) {
|
|
19
|
+
const code = cause.message.split(":")[0]?.trim() ?? "";
|
|
20
|
+
return SERVER_CODE_KEYS[code] ?? "kumiko.widget.upload.error";
|
|
21
|
+
}
|
|
22
|
+
return "kumiko.widget.upload.error";
|
|
23
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type FileUploadFormDataInput } from "./file-upload-form-data.ts";
|
|
2
|
-
export type FileRefTarget = Pick<FileUploadFormDataInput, "entityType" | "entityId" | "fieldName">;
|
|
2
|
+
export type FileRefTarget = Pick<FileUploadFormDataInput, "entityType" | "entityId" | "fieldName" | "actionForm">;
|
|
3
3
|
/** Carries the server's own `error` message when the upload endpoint sent one. */
|
|
4
4
|
export declare class FileUploadFailedError extends Error {
|
|
5
5
|
readonly fromServer: boolean;
|
|
@@ -21,6 +21,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
21
21
|
// state into a `draftsByCellId` map here that survives the layout swap.
|
|
22
22
|
import { ArrowDown, ArrowUp, Copy, Plus, Trash2 } from "lucide-react";
|
|
23
23
|
import { Fragment, useEffect, useRef, useState, } from "react";
|
|
24
|
+
import { fieldIssueText } from "../../field-issue-text.js";
|
|
24
25
|
import { applyFormatSpec } from "../../headless/index.js";
|
|
25
26
|
import { useLocale, useTranslation } from "../../index.js";
|
|
26
27
|
import { cn } from "../lib/cn.js";
|
|
@@ -113,8 +114,8 @@ export function computeScrollAffordance(scrollLeft, scrollWidth, clientWidth) {
|
|
|
113
114
|
};
|
|
114
115
|
}
|
|
115
116
|
function formatTotalValue(total, columns, currency, locale) {
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
117
|
+
const isMoney = total.money ?? columns.find((c) => c.field === total.field)?.type === "money";
|
|
118
|
+
if (isMoney)
|
|
118
119
|
return formatMoney(total.value, currency, locale);
|
|
119
120
|
return total.value.toLocaleString(locale);
|
|
120
121
|
}
|
|
@@ -131,7 +132,7 @@ function IssueMessages({ issues, testId, constrainWidth, }) {
|
|
|
131
132
|
const t = useTranslation();
|
|
132
133
|
if (issues === undefined || issues.length === 0)
|
|
133
134
|
return null;
|
|
134
|
-
return (_jsx("div", { role: "alert", "data-testid": testId, className: cn("text-xs text-destructive", constrainWidth === true && "max-w-[16rem] whitespace-normal break-words"), children: issues.map((issue) => (_jsx("div", { children: t
|
|
135
|
+
return (_jsx("div", { role: "alert", "data-testid": testId, className: cn("text-xs text-destructive", constrainWidth === true && "max-w-[16rem] whitespace-normal break-words"), children: issues.map((issue) => (_jsx("div", { children: fieldIssueText(t, issue) }, `${issue.path}:${issue.code}`))) }));
|
|
135
136
|
}
|
|
136
137
|
function readOnlyCellText(column, value, currency, locale) {
|
|
137
138
|
if (value === null || value === undefined || value === "")
|
|
@@ -335,7 +336,7 @@ export function EmbeddedListInput({ id, columns, rows, totals, currency, disable
|
|
|
335
336
|
};
|
|
336
337
|
const testIdFor = (suffix) => testId !== undefined ? `${testId}-${suffix}` : undefined;
|
|
337
338
|
if (rows.length === 0) {
|
|
338
|
-
return (_jsxs("div", { ref: containerRef, "data-testid": testId !== undefined ? `${testId}-empty` : "embedded-list-empty", className: "flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground", children: [_jsx("span", { children: emptyLabel }), showControls && (_jsxs(UiButton, { type: "button", variant: "outline", size: "sm", onClick: onAddRow, "data-testid": testId !== undefined ? `${testId}-empty-add` : "embedded-list-empty-add", children: [_jsx(Plus, { className: "size-4", "aria-hidden": "true" }), emptyCtaLabel] }))] }));
|
|
339
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { ref: containerRef, "data-testid": testId !== undefined ? `${testId}-empty` : "embedded-list-empty", className: "flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center text-sm text-muted-foreground", children: [_jsx("span", { children: emptyLabel }), showControls && (_jsxs(UiButton, { type: "button", variant: "outline", size: "sm", onClick: onAddRow, "data-testid": testId !== undefined ? `${testId}-empty-add` : "embedded-list-empty-add", children: [_jsx(Plus, { className: "size-4", "aria-hidden": "true" }), emptyCtaLabel] }))] }), _jsx(IssueMessages, { issues: listIssues, testId: testIdFor("list-issues") })] }));
|
|
339
340
|
}
|
|
340
341
|
const hasTotals = totals !== undefined && totals.length > 0;
|
|
341
342
|
return (_jsxs("div", { ref: containerRef, "data-testid": testId, className: "min-w-0", children: [!isMobile && (_jsxs("div", { "data-testid": testIdFor("desktop"), className: "hidden min-w-0 md:block", children: [_jsxs("div", { className: "min-w-0 overflow-hidden rounded-lg border bg-card", children: [_jsxs("div", { className: "relative min-w-0", children: [_jsx("div", { ref: setScrollContainerEl, "data-testid": testIdFor("desktop-scroll"), className: "overflow-x-auto [&_[data-slot=table-container]]:overflow-x-visible", children: _jsxs(Table, { className: "w-full table-fixed", style: { minWidth: `${tableMinWidthRem(columns, showControls)}rem` }, children: [_jsxs("colgroup", { children: [columns.map((column) => (_jsx("col", { style: columnWidthStyle(column.type) }, column.field))), showControls && _jsx("col", { style: { width: `${ACTIONS_COLUMN_REM}rem` } })] }), _jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [columns.map((column) => (_jsx(TableHead, { title: column.label, className: cn("truncate", columnAlignClass(column.type)), children: column.label }, column.field))), showControls && (_jsx(TableHead, { className: cn("sticky right-0 z-10 whitespace-nowrap bg-muted px-1 text-right", isHorizontallyScrollable &&
|
|
@@ -7,6 +7,7 @@ export type FileUploadInputProps = {
|
|
|
7
7
|
readonly accept?: readonly string[];
|
|
8
8
|
readonly disabled?: boolean;
|
|
9
9
|
readonly entityType?: string;
|
|
10
|
+
readonly uploadActionForm?: string;
|
|
10
11
|
readonly entityId?: string;
|
|
11
12
|
readonly fieldName?: string;
|
|
12
13
|
readonly imageVariant?: string;
|
|
@@ -18,4 +19,4 @@ export type FileUploadInputProps = {
|
|
|
18
19
|
readonly pickLabel?: string;
|
|
19
20
|
readonly testId?: string;
|
|
20
21
|
};
|
|
21
|
-
export declare function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }: FileUploadInputProps): ReactNode;
|
|
22
|
+
export declare function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, uploadActionForm, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }: FileUploadInputProps): ReactNode;
|
|
@@ -9,12 +9,13 @@ import { useRef, useState } from "react";
|
|
|
9
9
|
import { useTranslation } from "../../index.js";
|
|
10
10
|
import { toAcceptAttr } from "../lib/accept-attr.js";
|
|
11
11
|
import { resizeImageBeforeUpload } from "../lib/resize-image.js";
|
|
12
|
-
import {
|
|
12
|
+
import { uploadErrorKey } from "../lib/upload-error-key.js";
|
|
13
|
+
import { uploadFileRef } from "../lib/upload-file-ref.js";
|
|
13
14
|
import { Button as UiButton } from "../ui/button.js";
|
|
14
15
|
import { FileNameLink, FileThumbnail } from "./file-thumbnail.js";
|
|
15
16
|
// React's input typings have no `webkitdirectory`; spreading keeps it typed-cast-free.
|
|
16
17
|
const DIRECTORY_INPUT_ATTRS = { webkitdirectory: "", multiple: true };
|
|
17
|
-
export function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }) {
|
|
18
|
+
export function FileUploadInput({ kind, id, value, onChange, accept, disabled, entityType, uploadActionForm, entityId, fieldName, imageVariant, capture, directory, onFilesPicked, pickLabel, testId, }) {
|
|
18
19
|
const t = useTranslation();
|
|
19
20
|
const inputRef = useRef(null);
|
|
20
21
|
const [uploading, setUploading] = useState(false);
|
|
@@ -33,12 +34,15 @@ export function FileUploadInput({ kind, id, value, onChange, accept, disabled, e
|
|
|
33
34
|
setError(null);
|
|
34
35
|
try {
|
|
35
36
|
const uploadFile = await resizeImageBeforeUpload(file);
|
|
36
|
-
onChange(await uploadFileRef(uploadFile, {
|
|
37
|
+
onChange(await uploadFileRef(uploadFile, {
|
|
38
|
+
entityType,
|
|
39
|
+
entityId,
|
|
40
|
+
fieldName,
|
|
41
|
+
actionForm: uploadActionForm,
|
|
42
|
+
}));
|
|
37
43
|
}
|
|
38
|
-
catch (
|
|
39
|
-
setError(
|
|
40
|
-
? error.message
|
|
41
|
-
: t("kumiko.field.file-upload.failed"));
|
|
44
|
+
catch (cause) {
|
|
45
|
+
setError(t(uploadErrorKey(cause)));
|
|
42
46
|
}
|
|
43
47
|
finally {
|
|
44
48
|
setUploading(false);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
export type FilesDropzoneInputProps = {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly value: readonly string[];
|
|
5
|
+
readonly onChange: (fileIds: readonly string[]) => void;
|
|
6
|
+
readonly accept?: readonly string[];
|
|
7
|
+
readonly maxSize?: string;
|
|
8
|
+
readonly maxCount?: number;
|
|
9
|
+
readonly disabled?: boolean;
|
|
10
|
+
readonly entityType?: string;
|
|
11
|
+
readonly uploadActionForm?: string;
|
|
12
|
+
readonly entityId?: string;
|
|
13
|
+
readonly fieldName?: string;
|
|
14
|
+
readonly testId?: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCount, disabled, entityType, uploadActionForm, entityId, fieldName, testId, }: FilesDropzoneInputProps): ReactNode;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// Drop zone for a `files` / `images` field with `display: "dropzone"`: one status row per picked
|
|
3
|
+
// file, the field value is the array of FileRef ids of the files that uploaded. Failed files stay
|
|
4
|
+
// out of the value; removing a row removes its id.
|
|
5
|
+
import { useEffect, useRef } from "react";
|
|
6
|
+
import { useTranslation } from "../../index.js";
|
|
7
|
+
import { FileCountLimitError, uploadErrorKey } from "../lib/upload-error-key.js";
|
|
8
|
+
import { uploadFileRef } from "../lib/upload-file-ref.js";
|
|
9
|
+
import { UploadZone } from "../widgets/upload-zone.js";
|
|
10
|
+
export function FilesDropzoneInput({ id, value, onChange, accept, maxSize, maxCount, disabled, entityType, uploadActionForm, entityId, fieldName, testId, }) {
|
|
11
|
+
const t = useTranslation();
|
|
12
|
+
// Uploads of one batch finish independently; building each next value from the render's
|
|
13
|
+
// `value` would drop the ids of the others. Re-synced whenever the owner changes the value.
|
|
14
|
+
const latestIds = useRef(value);
|
|
15
|
+
const uploadsRunning = useRef(0);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
latestIds.current = value;
|
|
18
|
+
}, [value]);
|
|
19
|
+
const target = {
|
|
20
|
+
entityType,
|
|
21
|
+
entityId,
|
|
22
|
+
fieldName,
|
|
23
|
+
actionForm: uploadActionForm,
|
|
24
|
+
};
|
|
25
|
+
async function uploadOne(file) {
|
|
26
|
+
if (maxCount !== undefined && latestIds.current.length + uploadsRunning.current >= maxCount) {
|
|
27
|
+
throw new FileCountLimitError();
|
|
28
|
+
}
|
|
29
|
+
uploadsRunning.current += 1;
|
|
30
|
+
try {
|
|
31
|
+
const fileId = await uploadFileRef(file, target);
|
|
32
|
+
latestIds.current = [...latestIds.current, fileId];
|
|
33
|
+
onChange(latestIds.current);
|
|
34
|
+
return fileId;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
uploadsRunning.current -= 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function removeOne(fileId) {
|
|
41
|
+
latestIds.current = latestIds.current.filter((existing) => existing !== fileId);
|
|
42
|
+
onChange(latestIds.current);
|
|
43
|
+
}
|
|
44
|
+
const limits = [
|
|
45
|
+
accept !== undefined && accept.length > 0 ? accept.join(", ").toUpperCase() : undefined,
|
|
46
|
+
maxSize !== undefined ? t("kumiko.field.dropzone.limit-size", { size: maxSize }) : undefined,
|
|
47
|
+
maxCount !== undefined
|
|
48
|
+
? t("kumiko.field.dropzone.limit-count", { count: maxCount })
|
|
49
|
+
: undefined,
|
|
50
|
+
].filter((part) => part !== undefined);
|
|
51
|
+
return (_jsx(UploadZone, { testId: testId ?? `${id}-dropzone`, title: t("kumiko.field.dropzone.title"), ...(limits.length > 0 && { hint: limits.join(" · ") }), ...(accept !== undefined && { accept }), ...(disabled !== undefined && { disabled }), doneValues: value, onUpload: uploadOne, onRemove: removeOne, describeError: (cause) => t(uploadErrorKey(cause), { count: maxCount }) }));
|
|
52
|
+
}
|