@cosmicdrift/kumiko-renderer 0.377.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 +51 -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/form-derivation-patch.d.ts +8 -0
- package/dist/components/form-derivation-patch.js +24 -0
- package/dist/components/form-screen-layout.d.ts +13 -0
- package/dist/components/form-screen-layout.js +13 -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 +7 -1
- package/dist/components/render-edit.js +38 -6
- package/dist/components/render-field.d.ts +3 -1
- package/dist/components/render-field.js +27 -8
- 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/file-ids-of.d.ts +1 -0
- package/dist/file-ids-of.js +7 -0
- package/dist/flow-diagram/icons.d.ts +3 -0
- package/dist/flow-diagram/icons.js +14 -0
- package/dist/flow-diagram/index.d.ts +2 -0
- package/dist/flow-diagram/index.js +1 -0
- package/dist/flow-diagram/render.d.ts +3 -0
- package/dist/flow-diagram/render.js +234 -0
- package/dist/flow-diagram/types.d.ts +18 -0
- package/dist/flow-diagram/types.js +1 -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 +8 -1
- package/dist/headless/view-model/edit.js +23 -7
- package/dist/headless/view-model/types.d.ts +11 -3
- 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 +28 -3
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -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 +39 -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/index.d.ts +2 -2
- package/dist/web/index.js +1 -1
- 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-thumbnail.d.ts +6 -0
- package/dist/web/primitives/file-thumbnail.js +19 -19
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +11 -7
- package/dist/web/primitives/file-value-list.d.ts +3 -0
- package/dist/web/primitives/file-value-list.js +22 -0
- 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 +65 -30
- 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/bounded-load-cache.d.ts +1 -0
- package/dist/web/widgets/bounded-load-cache.js +25 -0
- package/dist/web/widgets/drawer.js +37 -2
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.d.ts +10 -0
- package/dist/web/widgets/pdf-preview.js +140 -0
- package/dist/web/widgets/upload-zone.d.ts +11 -2
- package/dist/web/widgets/upload-zone.js +30 -7
- package/package.json +14 -5
- package/src/changes.json +143 -0
- package/src/web/styles.css +13 -9
|
@@ -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 &&
|
|
@@ -8,6 +8,11 @@ export declare function FileThumbnail({ fileId, variant, className, testId, }: {
|
|
|
8
8
|
readonly className?: string;
|
|
9
9
|
readonly testId?: string;
|
|
10
10
|
}): ReactNode;
|
|
11
|
+
type FileMeta = {
|
|
12
|
+
readonly fileName: string;
|
|
13
|
+
readonly mimeType: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
export declare function useFileMeta(fileId: string): FileMeta | null;
|
|
11
16
|
export declare function FileNameLink({ fileId, fallbackLabel, className, }: {
|
|
12
17
|
readonly fileId: string;
|
|
13
18
|
readonly fallbackLabel: string;
|
|
@@ -23,3 +28,4 @@ export declare function FileDisplayCell({ value, display, fallbackLabel, compact
|
|
|
23
28
|
readonly fallbackLabel: string;
|
|
24
29
|
readonly compact?: boolean;
|
|
25
30
|
}): ReactNode;
|
|
31
|
+
export {};
|
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
|
|
3
3
|
import { ImageIcon } from "lucide-react";
|
|
4
4
|
import { useEffect, useState } from "react";
|
|
5
|
+
import { fileIdsOf } from "../../file-ids-of.js";
|
|
5
6
|
import { cn } from "../lib/cn.js";
|
|
6
7
|
// Stored values are untrusted: only UUIDs reach a request, and path segments are
|
|
7
8
|
// encoded so an id like "../x" or "a?b" cannot redirect the request.
|
|
@@ -33,42 +34,41 @@ function ThumbnailChain({ fileId, variant, className, testId, }) {
|
|
|
33
34
|
export function FileThumbnail({ fileId, variant, className = "h-24 w-32", testId, }) {
|
|
34
35
|
return (_jsx(ThumbnailChain, { fileId: fileId, variant: variant, className: className, testId: testId }, `${fileId}:${variant ?? ""}`));
|
|
35
36
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
function parseFileMeta(value) {
|
|
38
|
+
if (typeof value !== "object" || value === null || !("fileName" in value))
|
|
39
|
+
return null;
|
|
40
|
+
if (typeof value.fileName !== "string")
|
|
41
|
+
return null;
|
|
42
|
+
const mimeType = "mimeType" in value && typeof value.mimeType === "string" ? value.mimeType : undefined;
|
|
43
|
+
return { fileName: value.fileName, mimeType };
|
|
41
44
|
}
|
|
42
|
-
export function
|
|
43
|
-
const [
|
|
45
|
+
export function useFileMeta(fileId) {
|
|
46
|
+
const [meta, setMeta] = useState(null);
|
|
44
47
|
const validId = isUuid(fileId);
|
|
45
48
|
useEffect(() => {
|
|
46
49
|
let cancelled = false;
|
|
47
|
-
|
|
50
|
+
setMeta(null);
|
|
48
51
|
if (!validId)
|
|
49
52
|
return;
|
|
50
53
|
fetch(`${fileOriginalUrl(fileId)}/meta`)
|
|
51
54
|
.then((res) => (res.ok ? res.json() : null))
|
|
52
|
-
.then((
|
|
53
|
-
if (!cancelled
|
|
54
|
-
|
|
55
|
+
.then((json) => {
|
|
56
|
+
if (!cancelled)
|
|
57
|
+
setMeta(parseFileMeta(json));
|
|
55
58
|
})
|
|
56
59
|
.catch(() => { });
|
|
57
60
|
return () => {
|
|
58
61
|
cancelled = true;
|
|
59
62
|
};
|
|
60
63
|
}, [fileId, validId]);
|
|
61
|
-
|
|
64
|
+
return meta;
|
|
65
|
+
}
|
|
66
|
+
export function FileNameLink({ fileId, fallbackLabel, className, }) {
|
|
67
|
+
const fileName = useFileMeta(fileId)?.fileName ?? null;
|
|
68
|
+
if (!isUuid(fileId))
|
|
62
69
|
return _jsx("span", { className: cn("text-sm", className), children: fallbackLabel });
|
|
63
70
|
return (_jsx("a", { href: fileOriginalUrl(fileId), target: "_blank", rel: "noreferrer", className: cn("text-sm underline underline-offset-2", className), children: fileName ?? fallbackLabel }));
|
|
64
71
|
}
|
|
65
|
-
function fileIdsOf(value) {
|
|
66
|
-
if (typeof value === "string")
|
|
67
|
-
return value === "" ? [] : [value];
|
|
68
|
-
if (!Array.isArray(value))
|
|
69
|
-
return [];
|
|
70
|
-
return value.filter((entry) => typeof entry === "string" && entry !== "");
|
|
71
|
-
}
|
|
72
72
|
// Height-bound, width follows the aspect ratio within a clamp, so a portrait and
|
|
73
73
|
// a landscape photo both read as a preview without stretching the column.
|
|
74
74
|
const TABLE_THUMBNAIL_CLASS = "h-16 min-w-16 max-w-24 object-cover";
|
|
@@ -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,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { isUuid } from "@cosmicdrift/kumiko-types/identifiers";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useTranslation } from "../../index.js";
|
|
5
|
+
import { Button } from "../ui/button.js";
|
|
6
|
+
import { PdfPreview } from "../widgets/pdf-preview.js";
|
|
7
|
+
import { fileOriginalUrl, useFileMeta } from "./file-thumbnail.js";
|
|
8
|
+
const PDF_MIME_TYPE = "application/pdf";
|
|
9
|
+
function FileValueRow({ fileId, testId, }) {
|
|
10
|
+
const t = useTranslation();
|
|
11
|
+
const meta = useFileMeta(fileId);
|
|
12
|
+
const [previewOpen, setPreviewOpen] = useState(false);
|
|
13
|
+
if (!isUuid(fileId))
|
|
14
|
+
return _jsx("span", { className: "text-sm", children: fileId });
|
|
15
|
+
const downloadUrl = fileOriginalUrl(fileId);
|
|
16
|
+
return (_jsxs("li", { className: "flex flex-col gap-2", "data-testid": testId, children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx("span", { className: "text-sm", children: meta?.fileName ?? fileId.slice(0, 8) }), _jsx("a", { href: downloadUrl, download: meta?.fileName ?? "", className: "text-primary text-sm underline underline-offset-2", ...(testId !== undefined && { "data-testid": `${testId}-download` }), children: t("kumiko.field.file-list.download") }), meta?.mimeType === PDF_MIME_TYPE && (_jsx(Button, { type: "button", variant: "link", size: "sm", "aria-expanded": previewOpen, onClick: () => setPreviewOpen((open) => !open), ...(testId !== undefined && { "data-testid": `${testId}-preview-toggle` }), children: t(previewOpen ? "kumiko.field.file-list.hidePreview" : "kumiko.field.file-list.preview") }))] }), previewOpen && (_jsx(PdfPreview, { source: { url: downloadUrl }, testId: `file-preview-${fileId}` }))] }));
|
|
17
|
+
}
|
|
18
|
+
export function FileValueList({ fileIds, testId }) {
|
|
19
|
+
if (fileIds.length === 0)
|
|
20
|
+
return null;
|
|
21
|
+
return (_jsx("ul", { className: "m-0 flex list-none flex-col gap-3 p-0", "data-testid": testId, children: fileIds.map((fileId) => (_jsx(FileValueRow, { fileId: fileId, ...(testId !== undefined && { testId: `${testId}-${fileId}` }) }, fileId))) }));
|
|
22
|
+
}
|
|
@@ -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
|
+
}
|