From f35dcdef05a8d56ce69f2f2d1d42dd34867450e2 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Tue, 15 Sep 2026 16:38:02 +0200 Subject: [PATCH 01/12] Prepare package exports for ESM-only Uppy 5 - align the supported runtime with the Node 24 frontend toolchain - add ESM and CommonJS export smoke tests to prevent packaging regressions - fix existing CommonJS dependency interop in the ESM build - fix declaration generation for the public utils export --- .github/workflows/test-code.yml | 3 +++ CHANGELOG.md | 8 ++++++-- package.json | 5 ++++- scripts/test-package-exports-commonjs.cjs | 5 +++++ scripts/test-package-exports-esm.mjs | 5 +++++ src/common/index.ts | 4 ++-- src/common/utils/truncateMarkdownDisplay.ts | 2 +- src/components/AutoSuggestion/AutoSuggestion.tsx | 3 ++- src/extensions/codemirror/debouncedLinter.ts | 3 ++- 9 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 scripts/test-package-exports-commonjs.cjs create mode 100644 scripts/test-package-exports-esm.mjs diff --git a/.github/workflows/test-code.yml b/.github/workflows/test-code.yml index 33004034..467b677d 100644 --- a/.github/workflows/test-code.yml +++ b/.github/workflows/test-code.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - ".github/workflows/test-code.yml" + - ".typescript/**" + - "scripts/**" - "src/**.js" - "src/**.ts" - "src/**.tsx" @@ -24,4 +26,5 @@ jobs: - run: yarn install - run: yarn compile - run: yarn compile-scss + - run: yarn test:package - run: yarn test:ci diff --git a/CHANGELOG.md b/CHANGELOG.md index fe232e53..a7e3b000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- Package smoke tests for the built ESM and CommonJS root exports - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` - `` @@ -36,8 +37,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Upgrading base libraries - Carbon, Codemirror, React-Flow -- minimum node version (`engines.node`) is `18.19.0` now - - the build of the ESM distribution needs a synchronous `import.meta.resolve`, which is only available since this version +- Minimum Node.js version (`engines.node`) is `24.11.1` now, matching the frontend build image, + `.nvmrc` and CI - `` - the used `Label` element gets the `eccgui-fielditem__label` class now - `` @@ -48,6 +49,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Fixed +- ESM distribution + - CommonJS `he` and `lodash` are consumed through their interoperable default exports + - `TruncateMarkdownDisplayType` is exported so the declaration build can name the public `utils` type - `` - fix description and story to point out that `PropertyValueList` need always to be used as wrapper - `` diff --git a/package.json b/package.json index 8f3bf94c..d6b1e28b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "registry": "https://registry.npmjs.org" }, "engines": { - "node": ">=18.19.0" + "node": ">=24.11.1" }, "style": "src/index.scss", "main": "dist/cjs/index.js", @@ -61,6 +61,9 @@ "test:ci": "jest --ci --reporters='default'", "test:coverage": "jest --collectCoverage", "test:generate-output": "jest --json --outputFile=.jest-test-results.json", + "test:package:esm": "node ./scripts/test-package-exports-esm.mjs", + "test:package:cjs": "node ./scripts/test-package-exports-commonjs.cjs", + "test:package": "yarn build:all && yarn test:package:esm && yarn test:package:cjs", "test:clean": "rimraf .jest-test-results.json && rimraf coverage/", "autolint:scripts": "eslint --fix .storybook/ blueprint/ scripts/ src/ index.ts || exit 0", "autolint:styles": "stylelint \"{.storybook,src}/**/*.{css,scss}\" --fix || exit 0", diff --git a/scripts/test-package-exports-commonjs.cjs b/scripts/test-package-exports-commonjs.cjs new file mode 100644 index 00000000..f612ba19 --- /dev/null +++ b/scripts/test-package-exports-commonjs.cjs @@ -0,0 +1,5 @@ +const assert = require("node:assert/strict"); + +const guiElements = require("@eccenca/gui-elements"); + +assert.ok("Button" in guiElements, "The CommonJS root export must expose gui-elements components"); diff --git a/scripts/test-package-exports-esm.mjs b/scripts/test-package-exports-esm.mjs new file mode 100644 index 00000000..e6c5cf3d --- /dev/null +++ b/scripts/test-package-exports-esm.mjs @@ -0,0 +1,5 @@ +import assert from "node:assert/strict"; + +import * as guiElements from "@eccenca/gui-elements"; + +assert.ok("Button" in guiElements, "The ESM root export must expose gui-elements components"); diff --git a/src/common/index.ts b/src/common/index.ts index 65c942e5..4a47e559 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,4 +1,4 @@ -import { decode } from "he"; +import he from "he"; import { invisibleZeroWidthCharacters } from "./utils/characters"; import { colorCalculateDistance } from "./utils/colorCalculateDistance"; @@ -28,5 +28,5 @@ export const utils = { textToColorHash, reduceToText, truncateMarkdownDisplay, - decodeHtmlEntities: decode, + decodeHtmlEntities: he.decode, }; diff --git a/src/common/utils/truncateMarkdownDisplay.ts b/src/common/utils/truncateMarkdownDisplay.ts index 2754ee04..450d665a 100644 --- a/src/common/utils/truncateMarkdownDisplay.ts +++ b/src/common/utils/truncateMarkdownDisplay.ts @@ -8,7 +8,7 @@ interface MarkdownWithCutOffProps extends Omit { cutOff: NonNullable; } -interface TruncateMarkdownDisplayType { +export interface TruncateMarkdownDisplayType { ( /** * Markdown element with mandatory `cutOff` property. diff --git a/src/components/AutoSuggestion/AutoSuggestion.tsx b/src/components/AutoSuggestion/AutoSuggestion.tsx index ff9a7f59..7dce2a9a 100644 --- a/src/components/AutoSuggestion/AutoSuggestion.tsx +++ b/src/components/AutoSuggestion/AutoSuggestion.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Classes as BlueprintClassNames } from "@blueprintjs/core"; import { EditorView, Rect } from "@codemirror/view"; -import { debounce } from "lodash"; +import lodash from "lodash"; import { IntentTypes } from "../../common/Intent"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; @@ -14,6 +14,7 @@ import { AutoSuggestionList } from "./AutoSuggestionList"; //custom components import ExtendedCodeEditor, { IRange } from "./ExtendedCodeEditor"; +const { debounce } = lodash; const EXTRA_VERTICAL_PADDING = 10; export enum OVERWRITTEN_KEYS { diff --git a/src/extensions/codemirror/debouncedLinter.ts b/src/extensions/codemirror/debouncedLinter.ts index b0af1076..8d31704d 100644 --- a/src/extensions/codemirror/debouncedLinter.ts +++ b/src/extensions/codemirror/debouncedLinter.ts @@ -1,9 +1,10 @@ import { Diagnostic } from "@codemirror/lint"; import { EditorView } from "@codemirror/view"; -import { debounce } from "lodash"; +import lodash from "lodash"; import { Linter } from "./types"; +const { debounce } = lodash; const DEBOUNCE_TIME = 500; export const debouncedLinter = (lintFunction: Linter, time = DEBOUNCE_TIME) => { From 18b623179b0e5d966b68ff5189c517d38b3a77c0 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Tue, 15 Sep 2026 18:22:16 +0200 Subject: [PATCH 02/12] Add accessible Uppy 5 upload foundation - introduce a public, Uppy-independent FileUpload API - implement headless file selection with Uppy 5 - provide accessible drop-zone and native file-picker semantics - support file restrictions, disabled state, and inline errors - add styles, Storybook examples, and focused tests --- CHANGELOG.md | 7 +- package.json | 2 + .../FileUpload/FileUpload.stories.tsx | 55 +++++ src/components/FileUpload/FileUpload.test.tsx | 204 ++++++++++++++++++ src/components/FileUpload/FileUpload.tsx | 199 +++++++++++++++++ src/components/FileUpload/fileupload.scss | 65 ++++++ src/components/FileUpload/index.ts | 2 + src/components/FileUpload/types.ts | 69 ++++++ src/components/FileUpload/uppyHeadless.ts | 68 ++++++ src/components/index.scss | 1 + src/components/index.ts | 1 + yarn.lock | 95 +++++++- 12 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 src/components/FileUpload/FileUpload.stories.tsx create mode 100644 src/components/FileUpload/FileUpload.test.tsx create mode 100644 src/components/FileUpload/FileUpload.tsx create mode 100644 src/components/FileUpload/fileupload.scss create mode 100644 src/components/FileUpload/index.ts create mode 100644 src/components/FileUpload/types.ts create mode 100644 src/components/FileUpload/uppyHeadless.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e3b000..e7ec68b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added - Package smoke tests for the built ESM and CommonJS root exports +- `` + - accessible native file picker and drag-and-drop selection based on Uppy 5 headless hooks + - accepted file type, maximum file size/count and disabled-state restrictions + - localized selection and inline restriction-error text + - optional integration with `ApplicationContainer` file-drop monitoring - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` - `` @@ -36,7 +41,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Changed - Upgrading base libraries - - Carbon, Codemirror, React-Flow + - Carbon, Codemirror, React-Flow, Uppy - Minimum Node.js version (`engines.node`) is `24.11.1` now, matching the frontend build image, `.nvmrc` and CI - `` diff --git a/package.json b/package.json index d6b1e28b..cda2b70a 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,8 @@ "@codemirror/lang-yaml": "^6.1.3", "@codemirror/legacy-modes": "^6.5.3", "@mavrin/remark-typograf": "^2.2.0", + "@uppy/core": "5.2.0", + "@uppy/react": "5.2.0", "@xyflow/react": "^12.11.5", "assert": "^2.1.0", "classnames": "^2.5.1", diff --git a/src/components/FileUpload/FileUpload.stories.tsx b/src/components/FileUpload/FileUpload.stories.tsx new file mode 100644 index 00000000..8cd6d248 --- /dev/null +++ b/src/components/FileUpload/FileUpload.stories.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Meta, StoryFn } from "@storybook/react"; + +import { FileUpload, FileUploadProps } from "../../index"; + +const defaultArgs: FileUploadProps = { + name: "Upload graph file", + endpoint: "/files", + acceptedFileTypes: [".ttl", ".nt", ".rdf"], + maxFileSize: 10_000_000, + labels: { + dropHereOr: "Drop a graph file here or", + browse: "browse files", + selectedFile: (file) => `Selected ${file.name}`, + }, + instructions: "Turtle, N-Triples or RDF/XML; maximum 10 MB. Press Enter or Space to browse.", +}; + +export default { + title: "Forms/FileUpload", + component: FileUpload, + args: defaultArgs, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Idle = Template.bind({}); + +const DraggingTemplate: StoryFn = (args) => { + const storyRef = React.useRef(null); + + React.useEffect(() => { + storyRef.current + ?.querySelector('[data-dropzone-for="Files"]') + ?.dispatchEvent(new Event("dragenter", { bubbles: true, cancelable: true })); + }, []); + + return ( +
+ +
+ ); +}; + +export const Dragging = DraggingTemplate.bind({}); + +export const Disabled = Template.bind({}); +Disabled.args = { + disabled: true, +}; + +export const MultipleFiles = Template.bind({}); +MultipleFiles.args = { + maxNumberOfFiles: 3, +}; diff --git a/src/components/FileUpload/FileUpload.test.tsx b/src/components/FileUpload/FileUpload.test.tsx new file mode 100644 index 00000000..82f5fe2f --- /dev/null +++ b/src/components/FileUpload/FileUpload.test.tsx @@ -0,0 +1,204 @@ +import React from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import { ApplicationContainer } from "../Application"; + +import FileUpload from "./FileUpload"; + +const labels = { + dropHereOr: "Drop a project file here or", + browse: "browse files", +}; + +const renderFileUpload = (props: Partial> = {}) => + render( + , + ); + +describe("FileUpload", () => { + it("renders a named group and a single explicitly named file-selection button", () => { + renderFileUpload({ instructions: "Turtle files up to 10 MB" }); + + const group = screen.getByRole("group", { name: "Project file upload" }); + const button = within(group).getByRole("button", { name: /browse files/i }); + const input = group.querySelector("input[type=file]") as HTMLInputElement; + + expect(button.tagName).toBe("BUTTON"); + expect(button).toHaveAttribute("role", "button"); + expect(button).toHaveAttribute("type", "button"); + expect(button).toHaveAttribute("aria-controls", input.id); + expect(button).toHaveAccessibleDescription(expect.stringContaining("Turtle files up to 10 MB")); + expect(group).toHaveAccessibleDescription(expect.stringContaining("Turtle files up to 10 MB")); + expect(input).toHaveAttribute("accept", ".ttl"); + expect(input).not.toHaveAttribute("multiple"); + expect(input).toHaveAttribute("tabindex", "-1"); + expect(group.querySelector('[data-dropzone-for="Files"]')).toBeInTheDocument(); + expect(group).not.toHaveAttribute("aria-busy"); + expect(within(group).getAllByRole("button")).toHaveLength(1); + }); + + it("creates stable, unique relationships for multiple uploaders", () => { + const { rerender } = render( + <> + + + , + ); + + const [firstButton, secondButton] = screen.getAllByRole("button", { name: /browse files/i }); + const firstInputId = firstButton.getAttribute("aria-controls"); + const secondInputId = secondButton.getAttribute("aria-controls"); + + expect(firstInputId).not.toBe(secondInputId); + expect(document.getElementById(firstInputId!)).toBeInstanceOf(HTMLInputElement); + expect(document.getElementById(secondInputId!)).toBeInstanceOf(HTMLInputElement); + + rerender( + <> + + + , + ); + + expect(screen.getAllByRole("button", { name: /browse files/i })[0]).toHaveAttribute( + "aria-controls", + firstInputId, + ); + }); + + it("adds files selected through the native picker", () => { + renderFileUpload(); + const input = document.querySelector("input[type=file]") as HTMLInputElement; + + fireEvent.change(input, { target: { files: [new File(["data"], "vocabulary.ttl", { type: "text/turtle" })] } }); + + expect(screen.getByRole("status")).toHaveTextContent("vocabulary.ttl"); + }); + + it("opens the native picker exactly once per button activation", () => { + renderFileUpload(); + const input = document.querySelector("input[type=file]") as HTMLInputElement; + const inputClick = jest.spyOn(input, "click"); + + fireEvent.click(screen.getByRole("button", { name: /browse files/i })); + + expect(inputClick).toHaveBeenCalledTimes(1); + }); + + it("adds dropped files with or without an ApplicationContainer", () => { + const { rerender } = renderFileUpload(); + const file = new File(["data"], "vocabulary.ttl", { type: "text/turtle" }); + + fireEvent.drop(document.querySelector('[data-dropzone-for="Files"]')!, { dataTransfer: { files: [file] } }); + expect(screen.getByRole("status")).toHaveTextContent("vocabulary.ttl"); + + rerender( + + + , + ); + + fireEvent.drop(document.querySelector('[data-dropzone-for="Files"]')!, { dataTransfer: { files: [file] } }); + expect(screen.getByRole("status")).toHaveTextContent("vocabulary.ttl"); + }); + + it("exposes drag state and clears it when the pointer leaves", () => { + renderFileUpload(); + const dropzone = document.querySelector('[data-dropzone-for="Files"]')!; + + fireEvent.dragEnter(dropzone); + expect(dropzone).toHaveAttribute("data-state", "dragging"); + + fireEvent.dragLeave(dropzone); + expect(dropzone).toHaveAttribute("data-state", "idle"); + }); + + it.each([ + ["file type", { acceptedFileTypes: [".ttl"] }, new File(["data"], "invalid.txt")], + ["file size", { maxFileSize: 3 }, new File(["too large"], "large.ttl")], + ])("shows %s restriction failures as accessible errors", (_restriction, props, file) => { + renderFileUpload({ + ...props, + labels: { + ...labels, + restrictionError: (_error, file) => `File rejected: ${file?.name ?? "selection"}`, + }, + }); + const input = document.querySelector("input[type=file]") as HTMLInputElement; + + fireEvent.change(input, { target: { files: [file] } }); + + expect(screen.getByRole("alert")).toHaveTextContent("File rejected:"); + expect(screen.getByRole("button", { name: /browse files/i })).toHaveAccessibleDescription( + screen.getByRole("alert").textContent!, + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("rejects a selection that exceeds the maximum file count", () => { + renderFileUpload({ maxNumberOfFiles: 1 }); + const input = document.querySelector("input[type=file]") as HTMLInputElement; + + fireEvent.change(input, { + target: { files: [new File(["one"], "one.ttl"), new File(["two"], "two.ttl")] }, + }); + + expect(screen.getByRole("alert")).not.toBeEmptyDOMElement(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); + + it("uses the localized name as an aria-label when its visible label is hidden", () => { + renderFileUpload({ hideName: true }); + + const group = screen.getByRole("group", { name: "Project file upload" }); + expect(group).toHaveAttribute("aria-label", "Project file upload"); + expect(group).not.toHaveAttribute("aria-labelledby"); + expect(screen.queryByText("Project file upload")).not.toBeInTheDocument(); + }); + + it("uses the current error callback after rerender", () => { + const firstCallback = jest.fn(); + const currentCallback = jest.fn(); + const { rerender } = renderFileUpload({ onUploadError: firstCallback }); + + rerender( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["data"], "invalid.txt")] }, + }); + + expect(firstCallback).not.toHaveBeenCalled(); + expect(currentCallback).toHaveBeenCalledWith(expect.objectContaining({ kind: "restriction" })); + }); + + it("blocks picker and drop selection while disabled", () => { + renderFileUpload({ disabled: true }); + const button = screen.getByRole("button", { name: /browse files/i }); + const input = document.querySelector("input[type=file]") as HTMLInputElement; + + expect(button).toBeDisabled(); + expect(input).toBeDisabled(); + + fireEvent.change(input, { target: { files: [new File(["data"], "picker.ttl")] } }); + fireEvent.drop(document.querySelector('[data-dropzone-for="Files"]')!, { + dataTransfer: { files: [new File(["data"], "dropped.ttl")] }, + }); + + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx new file mode 100644 index 00000000..ea0dc42b --- /dev/null +++ b/src/components/FileUpload/FileUpload.tsx @@ -0,0 +1,199 @@ +import React from "react"; + +import { CLASSPREFIX as eccgui } from "../../configuration/constants"; +import Icon from "../Icon/Icon"; + +import { FileUploadError, FileUploadFile, FileUploadHandle, FileUploadProps } from "./types"; +import { HeadlessUppyFile, Uppy, UppyContextProvider, useDropzone, useFileInput } from "./uppyHeadless"; + +const publicFile = (file: { id: string; name?: string; type?: string; size?: number | null }): FileUploadFile => ({ + id: file.id, + name: file.name ?? "", + ...(file.type ? { type: file.type } : {}), + ...(typeof file.size === "number" ? { size: file.size } : {}), +}); + +interface FileSelectionProps { + buttonDescriptionIds: string | undefined; + disabled: boolean; + labels: FileUploadProps["labels"]; +} + +const FileSelection = ({ buttonDescriptionIds, disabled, labels }: FileSelectionProps) => { + const [dragging, setDragging] = React.useState(false); + const handleDragEnter = React.useCallback(() => setDragging(true), []); + const handleDragLeave = React.useCallback(() => setDragging(false), []); + const handleDrop = React.useCallback(() => setDragging(false), []); + const { getRootProps } = useDropzone({ + noClick: true, + onDragEnter: handleDragEnter, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }); + const { getButtonProps, getInputProps } = useFileInput(); + const dropzoneProps = getRootProps(); + const inputProps = getInputProps(); + const buttonProps = getButtonProps(); + const preventDisabledDrop = (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + + return ( +
+ + +
+ ); +}; + +function FileUploadInner( + { + id, + name, + hideName = false, + labels, + instructions, + acceptedFileTypes, + maxFileSize, + maxNumberOfFiles = 1, + onUploadError, + disabled = false, + }: FileUploadProps, + ref: React.ForwardedRef, +) { + const generatedId = React.useId().replace(/[^a-zA-Z0-9_-]/g, ""); + const widgetId = id ?? `file-upload-${generatedId}`; + const labelId = `${widgetId}-label`; + const instructionsId = `${widgetId}-instructions`; + const errorId = `${widgetId}-error`; + const [selectionStatus, setSelectionStatus] = React.useState(); + const [restrictionError, setRestrictionError] = React.useState(); + const labelsRef = React.useRef(labels); + const onUploadErrorRef = React.useRef(onUploadError); + labelsRef.current = labels; + onUploadErrorRef.current = onUploadError; + const [uppy] = React.useState( + () => + new Uppy({ + id: widgetId, + autoProceed: false, + restrictions: { allowedFileTypes: acceptedFileTypes, maxFileSize, maxNumberOfFiles }, + }), + ); + + React.useEffect(() => { + uppy.setOptions({ restrictions: { allowedFileTypes: acceptedFileTypes, maxFileSize, maxNumberOfFiles } }); + }, [acceptedFileTypes, maxFileSize, maxNumberOfFiles, uppy]); + + React.useEffect(() => { + const handleFileAdded = (file: HeadlessUppyFile) => { + const selectedFile = publicFile(file); + setRestrictionError(undefined); + setSelectionStatus(labelsRef.current.selectedFile?.(selectedFile) ?? selectedFile.name); + }; + const handleRestrictionFailed = (file: HeadlessUppyFile | undefined, error: Error) => { + const rejectedFile = file ? publicFile(file) : undefined; + const uploadError: FileUploadError = { + kind: "restriction", + error, + ...(rejectedFile ? { file: rejectedFile } : {}), + }; + setRestrictionError(labelsRef.current.restrictionError?.(error, rejectedFile) ?? error.message); + onUploadErrorRef.current?.(uploadError); + }; + + uppy.on("file-added", handleFileAdded); + uppy.on("restriction-failed", handleRestrictionFailed); + return () => { + uppy.off("file-added", handleFileAdded); + uppy.off("restriction-failed", handleRestrictionFailed); + }; + }, [uppy]); + + React.useEffect(() => () => uppy.destroy(), [uppy]); + + React.useImperativeHandle( + ref, + () => ({ + upload: async () => { + await uppy.upload(); + }, + cancel: () => uppy.cancelAll(), + reset: () => { + uppy.cancelAll(); + setSelectionStatus(undefined); + setRestrictionError(undefined); + }, + }), + [uppy], + ); + + const descriptionIds = [instructions ? instructionsId : undefined, restrictionError ? errorId : undefined] + .filter(Boolean) + .join(" "); + + return ( +
+ {!hideName && ( +
+ {name} +
+ )} + + + + {instructions && ( +
+ {instructions} +
+ )} + {restrictionError && ( + + )} + {selectionStatus &&
{selectionStatus}
} +
+ ); +} + +export const FileUpload = React.forwardRef(FileUploadInner) as ( + props: FileUploadProps & React.RefAttributes, +) => React.JSX.Element; + +export default FileUpload; diff --git a/src/components/FileUpload/fileupload.scss b/src/components/FileUpload/fileupload.scss new file mode 100644 index 00000000..2c76f741 --- /dev/null +++ b/src/components/FileUpload/fileupload.scss @@ -0,0 +1,65 @@ +.#{$eccgui}-fileupload { + display: grid; + gap: $eccgui-size-inline-whitespace; +} + +.#{$eccgui}-fileupload__label { + font-weight: $eccgui-font-weight-bold; +} + +.#{$eccgui}-fileupload__dropzone { + color: $eccgui-color-applicationheader-text; + background: $eccgui-color-workspace-background; + border: $button-border-width dashed eccgui-color-rgba($black, $pt-drop-shadow-opacity); + border-radius: $button-border-radius; + transition: + background-color 150ms, + border-color 150ms, + box-shadow 150ms; +} + +.#{$eccgui}-fileupload__dropzone--dragging { + color: $eccgui-color-accent; + background: eccgui-color-rgba($eccgui-color-accent, $eccgui-opacity-ghostly); + border-color: $eccgui-color-accent; + box-shadow: 0 0 $eccgui-size-block-whitespace $eccgui-color-accent inset; +} + +.#{$eccgui}-fileupload__dropzone--disabled { + opacity: $eccgui-opacity-disabled; +} + +.#{$eccgui}-fileupload__button { + display: flex; + flex-direction: column; + gap: $eccgui-size-inline-whitespace; + align-items: center; + justify-content: center; + width: 100%; + min-height: 8 * $eccgui-size-block-whitespace; + padding: $eccgui-size-block-whitespace; + font: inherit; + color: inherit; + cursor: pointer; + background: transparent; + border: 0; + border-radius: inherit; + + &:focus-visible { + @include focus-by-keyboard-static; + } + + &:disabled { + cursor: not-allowed; + } +} + +.#{$eccgui}-fileupload__instructions, +.#{$eccgui}-fileupload [role="status"] { + font-size: $eccgui-size-typo-caption; + color: eccgui-color-rgba($eccgui-color-applicationheader-text, $eccgui-opacity-muted); +} + +.#{$eccgui}-fileupload__error { + color: $eccgui-color-danger-text; +} diff --git a/src/components/FileUpload/index.ts b/src/components/FileUpload/index.ts new file mode 100644 index 00000000..e435f981 --- /dev/null +++ b/src/components/FileUpload/index.ts @@ -0,0 +1,2 @@ +export * from "./FileUpload"; +export * from "./types"; diff --git a/src/components/FileUpload/types.ts b/src/components/FileUpload/types.ts new file mode 100644 index 00000000..44235986 --- /dev/null +++ b/src/components/FileUpload/types.ts @@ -0,0 +1,69 @@ +export interface FileUploadHandle { + upload(): Promise; + cancel(): void; + reset(): void; +} + +export interface FileUploadFile { + id: string; + name: string; + type?: string; + size?: number; +} + +export interface FileUploadResponse { + body: T; + status: number; + file: FileUploadFile; +} + +export type FileUploadErrorKind = "restriction" | "response" | "transport" | "cancelled"; + +export interface FileUploadError { + kind: FileUploadErrorKind; + error: Error; + file?: FileUploadFile; + status?: number; +} + +export interface FileUploadLabels { + dropHereOr: string; + browse: string; + selectedFile?: (file: FileUploadFile) => string; + restrictionError?: (error: Error, file?: FileUploadFile) => string; +} + +export interface FileUploadResponseMetadata { + status: number; + responseText: string; +} + +export type FileUploadEndpoint = string | ((file: FileUploadFile) => string); +export type FileUploadHeaders = Record | (() => Record); + +export interface FileUploadProps { + /** Stable ID for the widget. A unique ID is generated when omitted. */ + id?: string; + /** Localized accessible name, displayed as the widget label by default. */ + name: string; + /** Hides the widget label visually while retaining it as its accessible name. */ + hideName?: boolean; + /** Localized selection labels. */ + labels: FileUploadLabels; + /** Additional localized file restrictions or interaction instructions. */ + instructions?: string; + endpoint: FileUploadEndpoint; + acceptedFileTypes?: string[]; + maxFileSize?: number; + maxNumberOfFiles?: number; + autoUpload?: boolean; + method?: "POST" | "PUT"; + headers?: FileUploadHeaders; + parseResponse?: (metadata: FileUploadResponseMetadata) => T; + onUploadStart?: () => void; + onUploadProgress?: (percentage: number) => void; + onUploadSuccess?: (response: FileUploadResponse) => void; + onUploadError?: (error: FileUploadError) => void; + onUploadEnd?: () => void; + disabled?: boolean; +} diff --git a/src/components/FileUpload/uppyHeadless.ts b/src/components/FileUpload/uppyHeadless.ts new file mode 100644 index 00000000..7546e985 --- /dev/null +++ b/src/components/FileUpload/uppyHeadless.ts @@ -0,0 +1,68 @@ +import React from "react"; +import UppyCore from "@uppy/core"; +import * as UppyReact from "@uppy/react"; + +export interface HeadlessUppyFile { + id: string; + name?: string; + type?: string; + size?: number | null; +} + +interface HeadlessUppyRestrictions { + allowedFileTypes?: string[]; + maxFileSize?: number; + maxNumberOfFiles?: number; +} + +interface HeadlessUppyOptions { + id: string; + autoProceed: boolean; + restrictions: HeadlessUppyRestrictions; +} + +export interface HeadlessUppy { + cancelAll(): void; + destroy(): void; + off(event: "file-added", callback: (file: HeadlessUppyFile) => void): void; + off(event: "restriction-failed", callback: (file: HeadlessUppyFile | undefined, error: Error) => void): void; + on(event: "file-added", callback: (file: HeadlessUppyFile) => void): void; + on(event: "restriction-failed", callback: (file: HeadlessUppyFile | undefined, error: Error) => void): void; + setOptions(options: { restrictions: HeadlessUppyRestrictions }): void; + upload(): Promise; +} + +interface HeadlessUppyConstructor { + new (options: HeadlessUppyOptions): HeadlessUppy; +} + +interface DropzoneRootProps { + onDragEnter: React.DragEventHandler; + onDragLeave: React.DragEventHandler; + onDragOver: React.DragEventHandler; + onDrop: React.DragEventHandler; +} + +interface FileInputProps { + accept?: string; + id: string; + multiple: boolean; + onChange: React.ChangeEventHandler; + type: "file"; +} + +interface UppyReactHeadless { + UppyContextProvider: React.ComponentType<{ children: React.ReactNode; uppy: HeadlessUppy }>; + useDropzone(options: { noClick: boolean; onDragEnter: () => void; onDragLeave: () => void; onDrop: () => void }): { + getRootProps(): DropzoneRootProps; + }; + useFileInput(): { + getButtonProps(): { onClick: React.MouseEventHandler; type: "button" }; + getInputProps(): FileInputProps; + }; +} + +// Source consumers can still hoist legacy Uppy declarations during the staged migration. +// Runtime package resolution remains on gui-elements' pinned Uppy 5 dependencies. +export const Uppy = UppyCore as unknown as HeadlessUppyConstructor; +export const { UppyContextProvider, useDropzone, useFileInput } = UppyReact as unknown as UppyReactHeadless; diff --git a/src/components/index.scss b/src/components/index.scss index b3852c48..cdf9be9e 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -10,6 +10,7 @@ @import "./Dialog/dialog"; @import "./FlexibleLayout/flexiblelayout"; @import "./Form/form"; +@import "./FileUpload/fileupload"; @import "./Grid/grid"; @import "./HoverToggler/hovertoggler"; @import "./Icon/icon"; diff --git a/src/components/index.ts b/src/components/index.ts index e4cd3125..6206cb8e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,6 +14,7 @@ export * from "./DecoupledOverlay/DecoupledOverlay"; export * from "./Depiction/Depiction"; export * from "./Dialog"; export * from "./FlexibleLayout"; +export * from "./FileUpload"; export * from "./Form"; export * from "./Grid"; export * from "./HoverToggler/HoverToggler"; diff --git a/yarn.lock b/yarn.lock index d00ef687..0c3b6ff4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3676,6 +3676,11 @@ resolved "https://registry.yarnpkg.com/@topoconfig/extends/-/extends-0.16.2.tgz#0741dbe5198a28f306a116498f7ded1089d0fadc" integrity sha512-sTF+qpWakr5jf1Hn/kkFSi833xPW15s/loMAiKSYSSVv4vDonxf6hwCGzMXjLq+7HZoaK6BgaV72wXr1eY7FcQ== +"@transloadit/prettier-bytes@^0.3.4": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz#0cca83975293e3f4990229914942c69714122ede" + integrity sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA== + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" @@ -4271,6 +4276,52 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== +"@uppy/components@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@uppy/components/-/components-1.2.0.tgz#20d815ca7920f8d875cb40b942a7e2878fc19ea8" + integrity sha512-rtIr+77Rw/q5Vw++xazF1dCg2d4A4zT9CV+ZyN8Rsx8xiIr2CxCR4TaHHBy+WeC0b7Mk6yNuJ0wUa34tFJ6pKg== + dependencies: + clsx "^2.1.1" + dequal "^2.0.3" + preact "^10.26.10" + pretty-bytes "^6.1.1" + +"@uppy/core@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@uppy/core/-/core-5.2.0.tgz#baeb0f9f1ef5bec1882eac912f5a41094af69f84" + integrity sha512-uvfNyz4cnaplt7LYJmEZHuqOuav0tKp4a9WKJIaH6iIj7XiqYvS2J5SEByexAlUFlzefOAyjzj4Ja2dd/8aMrw== + dependencies: + "@transloadit/prettier-bytes" "^0.3.4" + "@uppy/store-default" "^5.0.0" + "@uppy/utils" "^7.1.4" + lodash "^4.17.21" + mime-match "^1.0.2" + namespace-emitter "^2.0.1" + nanoid "^5.0.9" + preact "^10.5.13" + +"@uppy/react@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@uppy/react/-/react-5.2.0.tgz#ddee35205344efb80781bb86d13103a277ef79c0" + integrity sha512-6lzPutg2XGavs7P6ALmqOBPitd/Jqi3r1jCJQD5nx8xtNlBRwvlBR6hrZgo8XOI9cR+OaNDrJ0vEFxXDWb04Ag== + dependencies: + "@uppy/components" "^1.2.0" + preact "^10.26.10" + use-sync-external-store "^1.3.0" + +"@uppy/store-default@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@uppy/store-default/-/store-default-5.0.0.tgz#2466162857a999b8c99051426e5f412e2f34cb26" + integrity sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw== + +"@uppy/utils@^7.1.4": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@uppy/utils/-/utils-7.2.0.tgz#10212e92d8b57ff9d854276d056e98fdcd4643c9" + integrity sha512-6lC246qszMv6bTyl/+QyHwrudgeguWkA94ME1wHn+a6uRAvmtAEaUManIfGqTJfoKvWAiCJqdJPl5xRJjhAloQ== + dependencies: + lodash "^4.17.23" + preact "^10.26.10" + "@vitest/expect@3.2.4": version "3.2.4" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" @@ -5272,6 +5323,11 @@ clsx@^1.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -8713,7 +8769,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== -lodash@^4.17.20, lodash@^4.17.21, lodash@^4.18.1, lodash@~4.17.21: +lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.23, lodash@^4.18.1, lodash@~4.17.21: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== @@ -9385,6 +9441,13 @@ mime-db@^1.54.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== +mime-match@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mime-match/-/mime-match-1.0.2.tgz#3f87c31e9af1a5fd485fb9db134428b23bbb7ba8" + integrity sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg== + dependencies: + wildcard "^1.1.0" + mime-types@^2.1.27, mime-types@^2.1.31: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" @@ -9482,11 +9545,21 @@ n3@^1.26.0: buffer "^6.0.3" readable-stream "^4.0.0" +namespace-emitter@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/namespace-emitter/-/namespace-emitter-2.0.1.tgz#978d51361c61313b4e6b8cf6f3853d08dfa2b17c" + integrity sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g== + nanoid@^3.3.17: version "3.3.18" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== +nanoid@^5.0.9: + version "5.1.16" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.1.16.tgz#fe345c0a1f9007c32fbb5c139e1208bfd3f41ef7" + integrity sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ== + napi-postinstall@^0.3.0: version "0.3.2" resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.2.tgz#03c62080e88b311c4d7423b0f15f0c920bbcc626" @@ -10160,6 +10233,11 @@ postcss@^6.0.14, postcss@^8.2.7, postcss@^8.4.40, postcss@^8.5.16, postcss@^8.5. picocolors "^1.1.1" source-map-js "^1.2.1" +preact@^10.26.10, preact@^10.5.13: + version "10.29.8" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.29.8.tgz#fc85b82dc2474e245b1430b51781d417361f5fc3" + integrity sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -10175,6 +10253,11 @@ prettier@^3.9.6: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.6.tgz#b3ea5146515d40fc53f18aa63f74dfab1e10dbf6" integrity sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g== +pretty-bytes@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b" + integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ== + pretty-error@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6" @@ -12177,6 +12260,11 @@ use-sync-external-store@^1.2.2: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== +use-sync-external-store@^1.3.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz#6dcb66ef569e02f186af6b3d575f414ce746e18f" + integrity sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A== + util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -12500,6 +12588,11 @@ wicg-inert@^3.1.3: resolved "https://registry.yarnpkg.com/wicg-inert/-/wicg-inert-3.1.3.tgz#e53dbc9ac1e0d7f8c60f25e707614a835986272a" integrity sha512-5L0PKK7iP+0Q/jv2ccgmkz/pfXbumZtlEyWS/xnX+L+Og3f7WjL4+iEs18k4IuldOX3PgGpza3qGndL9xUBjCQ== +wildcard@^1.1.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-1.1.2.tgz#a7020453084d8cd2efe70ba9d3696263de1710a5" + integrity sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng== + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" From 6e2a1053e706d4137a63f37f77c7c9897de75281 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Wed, 16 Sep 2026 09:50:14 +0200 Subject: [PATCH 03/12] Fix drag state and drag monitoring in edge cases --- src/components/Application/helper.ts | 6 +-- src/components/FileUpload/FileUpload.test.tsx | 48 ++++++++++++++++++- src/components/FileUpload/FileUpload.tsx | 18 +++++-- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/components/Application/helper.ts b/src/components/Application/helper.ts index e3907da9..e97fa32a 100644 --- a/src/components/Application/helper.ts +++ b/src/components/Application/helper.ts @@ -29,7 +29,7 @@ export const useDropzoneMonitor = (enabledTypes: string[]) => { React.useEffect(() => { const monitor = window.document.body; let timestampMonitorEnabled = 0; - let processDragleave: any; + let processDragleave: ReturnType | undefined; const addMonitor = (event: DragEvent) => { // stop default, so that also no files cannot executed by browser without demand @@ -76,11 +76,11 @@ export const useDropzoneMonitor = (enabledTypes: string[]) => { if (monitor) { monitor.addEventListener("dragover", addMonitor); monitor.addEventListener("dragleave", removeMonitor); - monitor.addEventListener("drop", removeMonitor); + monitor.addEventListener("drop", removeMonitor, true); return () => { monitor.removeEventListener("dragover", addMonitor); monitor.removeEventListener("dragleave", removeMonitor); - monitor.removeEventListener("drop", removeMonitor); + monitor.removeEventListener("drop", removeMonitor, true); }; } return; diff --git a/src/components/FileUpload/FileUpload.test.tsx b/src/components/FileUpload/FileUpload.test.tsx index 82f5fe2f..26a4ba43 100644 --- a/src/components/FileUpload/FileUpload.test.tsx +++ b/src/components/FileUpload/FileUpload.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import "@testing-library/jest-dom"; import { ApplicationContainer } from "../Application"; +import { SimpleDialog } from "../Dialog"; import FileUpload from "./FileUpload"; @@ -110,14 +111,57 @@ describe("FileUpload", () => { expect(screen.getByRole("status")).toHaveTextContent("vocabulary.ttl"); }); - it("exposes drag state and clears it when the pointer leaves", () => { + it("clears application drag monitoring when a drop opens a modal", () => { + const UploadWithErrorDialog = () => { + const [dialogOpen, setDialogOpen] = React.useState(false); + + return ( + + setDialogOpen(true)} + /> + setDialogOpen(false)} + title="Upload failed" + transitionDuration={0} + > + The dropped file could not be used. + + + ); + }; + render(); + fireEvent.dragOver(document.body, { dataTransfer: { types: ["Files"] } }); + expect(document.body).toHaveAttribute("data-monitor-dropzone", "Files"); + + fireEvent.drop(document.querySelector('[data-dropzone-for="Files"]')!, { + dataTransfer: { files: [new File(["data"], "invalid.txt")], types: ["Files"] }, + }); + const monitorDropzone = document.body.dataset.monitorDropzone; + delete document.body.dataset.monitorDropzone; + + expect(screen.getByText("Upload failed")).toBeInTheDocument(); + expect(monitorDropzone).toBeUndefined(); + }); + + it("keeps the drag state while the pointer moves over nested content", () => { renderFileUpload(); const dropzone = document.querySelector('[data-dropzone-for="Files"]')!; + const button = screen.getByRole("button", { name: /drop a project file here/i }); fireEvent.dragEnter(dropzone); expect(dropzone).toHaveAttribute("data-state", "dragging"); - fireEvent.dragLeave(dropzone); + fireEvent.dragEnter(button); + fireEvent.dragLeave(button); + expect(dropzone).toHaveAttribute("data-state", "dragging"); + + fireEvent.dragLeave(dropzone, { relatedTarget: document.body }); expect(dropzone).toHaveAttribute("data-state", "idle"); }); diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index ea0dc42b..fba8cbe2 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -21,9 +21,21 @@ interface FileSelectionProps { const FileSelection = ({ buttonDescriptionIds, disabled, labels }: FileSelectionProps) => { const [dragging, setDragging] = React.useState(false); - const handleDragEnter = React.useCallback(() => setDragging(true), []); - const handleDragLeave = React.useCallback(() => setDragging(false), []); - const handleDrop = React.useCallback(() => setDragging(false), []); + const dragEntryCount = React.useRef(0); + const handleDragEnter = React.useCallback(() => { + dragEntryCount.current += 1; + setDragging(true); + }, []); + const handleDragLeave = React.useCallback(() => { + dragEntryCount.current = Math.max(0, dragEntryCount.current - 1); + if (dragEntryCount.current === 0) { + setDragging(false); + } + }, []); + const handleDrop = React.useCallback(() => { + dragEntryCount.current = 0; + setDragging(false); + }, []); const { getRootProps } = useDropzone({ noClick: true, onDragEnter: handleDragEnter, From 657a622ec2bb9591503324e16f019fbe6e814717 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Wed, 16 Sep 2026 11:30:59 +0200 Subject: [PATCH 04/12] Add uploads, progress, and error handling - Configure Uppy 5 XHR transport with dynamic endpoints and headers - Support automatic and imperative uploads with typed response parsing - Expose aggregate progress and lifecycle callbacks - Add accessible progress, status, busy and error semantics - Handle cancellation, reset and unmount safely - Add tests --- CHANGELOG.md | 6 +- package.json | 1 + .../FileUpload/FileUpload.stories.tsx | 3 + src/components/FileUpload/FileUpload.test.tsx | 3 + .../FileUpload/FileUpload.transport.test.tsx | 425 ++++++++++++++++++ src/components/FileUpload/FileUpload.tsx | 263 +++++++++-- src/components/FileUpload/fileupload.scss | 16 + src/components/FileUpload/types.ts | 17 +- src/components/FileUpload/uppyHeadless.ts | 39 +- yarn.lock | 43 +- 10 files changed, 777 insertions(+), 39 deletions(-) create mode 100644 src/components/FileUpload/FileUpload.transport.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e7ec68b2..70dc4fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `` - accessible native file picker and drag-and-drop selection based on Uppy 5 headless hooks - accepted file type, maximum file size/count and disabled-state restrictions - - localized selection and inline restriction-error text + - automatic or imperative XHR uploads with current endpoints, headers and typed response parsing + - aggregate upload progress, per-file success and batch lifecycle callbacks + - idempotent cancellation/reset and active-request teardown on unmount + - localized selection, success and inline restriction/transport/response-error text + - accessible busy, progress, alert and polite status semantics - optional integration with `ApplicationContainer` file-drop monitoring - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` diff --git a/package.json b/package.json index cda2b70a..a03b7498 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@mavrin/remark-typograf": "^2.2.0", "@uppy/core": "5.2.0", "@uppy/react": "5.2.0", + "@uppy/xhr-upload": "5.2.0", "@xyflow/react": "^12.11.5", "assert": "^2.1.0", "classnames": "^2.5.1", diff --git a/src/components/FileUpload/FileUpload.stories.tsx b/src/components/FileUpload/FileUpload.stories.tsx index 8cd6d248..85eaa338 100644 --- a/src/components/FileUpload/FileUpload.stories.tsx +++ b/src/components/FileUpload/FileUpload.stories.tsx @@ -11,6 +11,9 @@ const defaultArgs: FileUploadProps = { labels: { dropHereOr: "Drop a graph file here or", browse: "browse files", + uploadProgress: "Upload progress", + overallUploadProgress: "Overall upload progress", + completedFiles: (completed, total) => `${completed} of ${total} files completed`, selectedFile: (file) => `Selected ${file.name}`, }, instructions: "Turtle, N-Triples or RDF/XML; maximum 10 MB. Press Enter or Space to browse.", diff --git a/src/components/FileUpload/FileUpload.test.tsx b/src/components/FileUpload/FileUpload.test.tsx index 26a4ba43..1d25d1a2 100644 --- a/src/components/FileUpload/FileUpload.test.tsx +++ b/src/components/FileUpload/FileUpload.test.tsx @@ -11,6 +11,9 @@ import FileUpload from "./FileUpload"; const labels = { dropHereOr: "Drop a project file here or", browse: "browse files", + uploadProgress: "Upload progress", + overallUploadProgress: "Overall upload progress", + completedFiles: (completed: number, total: number) => `${completed} of ${total} files completed`, }; const renderFileUpload = (props: Partial> = {}) => diff --git a/src/components/FileUpload/FileUpload.transport.test.tsx b/src/components/FileUpload/FileUpload.transport.test.tsx new file mode 100644 index 00000000..aca692d5 --- /dev/null +++ b/src/components/FileUpload/FileUpload.transport.test.tsx @@ -0,0 +1,425 @@ +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import "@testing-library/jest-dom"; + +import FileUpload from "./FileUpload"; +import { FileUploadHandle } from "./types"; + +const labels = { + browse: "browse files", + completedFiles: (completed: number, total: number) => `${completed} of ${total} files completed`, + dropHereOr: "Drop files here or", + overallUploadProgress: "Overall upload progress", + responseError: (error: Error) => `Invalid response: ${error.message}`, + transportError: (error: Error) => `Upload failed: ${error.message}`, + uploadProgress: "Upload progress", + uploadedFile: (file: { name: string }) => `${file.name} uploaded`, +}; + +class ControlledXMLHttpRequest { + static requests: ControlledXMLHttpRequest[] = []; + + method = ""; + url = ""; + requestBody: Document | XMLHttpRequestBodyInit | null = null; + requestHeaders: Record = {}; + response: unknown; + responseText = ""; + responseType: XMLHttpRequestResponseType = ""; + status = 0; + statusText = ""; + withCredentials = false; + aborted = false; + sent = false; + onerror: (() => void | Promise) | null = null; + onload: (() => void | Promise) | null = null; + upload = { onprogress: null as ((event: ProgressEvent) => void) | null }; + + constructor() { + ControlledXMLHttpRequest.requests.push(this); + } + + abort() { + this.aborted = true; + } + + open(method: string, url: string) { + this.method = method; + this.url = url; + } + + send(body: Document | XMLHttpRequestBodyInit | null) { + this.requestBody = body; + this.sent = true; + } + + setRequestHeader(name: string, value: string) { + this.requestHeaders[name] = value; + } + + progress(loaded: number, total: number) { + this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent); + } + + async fail(message: string) { + this.statusText = message; + await this.onerror?.(); + } + + async respond(status: number, responseText: string) { + this.status = status; + this.responseText = responseText; + await this.onload?.(); + } +} + +class ControlledFormData { + append() {} +} + +const NativeXMLHttpRequest = global.XMLHttpRequest; +const NativeFormData = global.FormData; +const nativeAbortSignalAny = AbortSignal.any; + +const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => { + const controller = new AbortController(); + signals.forEach((signal) => { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", () => controller.abort(), { once: true }); + }); + return controller.signal; +}; + +const completeRequest = async (request: ControlledXMLHttpRequest, status: number, responseText: string) => { + await act(async () => { + await request.respond(status, responseText); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +const failRequest = async (request: ControlledXMLHttpRequest, message: string) => { + await act(async () => { + await request.fail(message); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +beforeEach(() => { + ControlledXMLHttpRequest.requests = []; + global.XMLHttpRequest = ControlledXMLHttpRequest as unknown as typeof XMLHttpRequest; + window.XMLHttpRequest = ControlledXMLHttpRequest as unknown as typeof XMLHttpRequest; + global.FormData = ControlledFormData as unknown as typeof FormData; + window.FormData = ControlledFormData as unknown as typeof FormData; + AbortSignal.any = combineAbortSignals; +}); + +afterEach(() => { + jest.restoreAllMocks(); + global.XMLHttpRequest = NativeXMLHttpRequest; + window.XMLHttpRequest = NativeXMLHttpRequest; + global.FormData = NativeFormData; + window.FormData = NativeFormData; + AbortSignal.any = nativeAbortSignalAny; +}); + +describe("FileUpload transport", () => { + it("uses current request configuration and emits the documented successful lifecycle", async () => { + const events: string[] = []; + const endpoint = jest.fn((file) => `/files/${file.name}`); + const headers = jest.fn(() => ({ Authorization: "current token" })); + const onUploadProgress = jest.fn((value) => events.push(`progress:${value}`)); + const onUploadSuccess = jest.fn((response) => events.push(`success:${response.body}`)); + render( + responseText.toUpperCase()} + onUploadStart={() => events.push("start")} + onUploadProgress={onUploadProgress} + onUploadSuccess={onUploadSuccess} + onUploadEnd={() => events.push("end")} + />, + ); + + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["contents"], "vocabulary.ttl")] }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); + const request = ControlledXMLHttpRequest.requests[0]; + + expect(request.method.toUpperCase()).toBe("PUT"); + expect(request.url).toBe("/files/vocabulary.ttl"); + expect(request.requestHeaders).toEqual({ Authorization: "current token" }); + expect(endpoint).toHaveBeenCalledWith(expect.objectContaining({ name: "vocabulary.ttl" })); + act(() => request.progress(4, 8)); + expect(screen.getByRole("progressbar", { name: "Upload progress" })).toHaveAttribute("aria-valuenow", "50"); + await completeRequest(request, 201, "upload-id"); + + await waitFor(() => expect(onUploadSuccess).toHaveBeenCalled()); + expect(onUploadSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + body: "UPLOAD-ID", + status: 201, + file: expect.objectContaining({ name: "vocabulary.ttl" }), + }), + ); + await waitFor(() => expect(events.at(-1)).toBe("end")); + expect(events).toEqual(["start", "progress:50", "success:UPLOAD-ID", "progress:100", "end"]); + expect(screen.getByRole("status")).toHaveTextContent("vocabulary.ttl uploaded"); + }); + + it("waits for the imperative upload call in manual mode and deduplicates concurrent calls", async () => { + const uploadRef = React.createRef(); + render( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["contents"], "manual.ttl")] }, + }); + expect(ControlledXMLHttpRequest.requests).toHaveLength(0); + + let firstUpload!: Promise; + let secondUpload!: Promise; + act(() => { + firstUpload = uploadRef.current!.upload(); + secondUpload = uploadRef.current!.upload(); + }); + expect(secondUpload).toBe(firstUpload); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); + await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "done"); + await firstUpload; + }); + + it("shows byte-aggregate progress and completion count only for multiple files", async () => { + const onUploadEnd = jest.fn(); + render( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { + files: [new File(["12345678"], "first.ttl"), new File(["12345678"], "second.ttl")], + }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(2)); + + act(() => ControlledXMLHttpRequest.requests[0].progress(4, 8)); + expect(screen.getByRole("progressbar", { name: "Overall upload progress" })).toHaveAttribute( + "aria-valuenow", + "25", + ); + expect(screen.getByText("0 of 2 files completed")).toBeInTheDocument(); + + await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "first"); + await waitFor(() => expect(screen.getByText("1 of 2 files completed")).toBeInTheDocument()); + expect(onUploadEnd).not.toHaveBeenCalled(); + await completeRequest(ControlledXMLHttpRequest.requests[1], 200, "second"); + await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); + }); + + it("reports mixed batch results per file and ends the batch once", async () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + const onUploadSuccess = jest.fn(); + const onUploadError = jest.fn(); + const onUploadEnd = jest.fn(); + render( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["first"], "first.ttl"), new File(["second"], "second.ttl")] }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(2)); + + await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "first-id"); + expect(onUploadSuccess).toHaveBeenCalledTimes(1); + expect(onUploadEnd).not.toHaveBeenCalled(); + for (let attempt = 1; attempt <= 4; attempt += 1) { + await waitFor(() => expect(ControlledXMLHttpRequest.requests[attempt]?.sent).toBe(true)); + await failRequest(ControlledXMLHttpRequest.requests[attempt], "Connection lost"); + } + + await waitFor(() => expect(onUploadError).toHaveBeenCalledTimes(1)); + expect(onUploadError).toHaveBeenCalledWith( + expect.objectContaining({ kind: "transport", file: expect.objectContaining({ name: "second.ttl" }) }), + ); + expect(onUploadEnd).toHaveBeenCalledTimes(1); + }); + + it("classifies parser failures as response errors with the HTTP status", async () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + const onUploadError = jest.fn(); + const onUploadEnd = jest.fn(); + render( + + name="Parsed upload" + endpoint="/files" + labels={labels} + parseResponse={() => { + throw new Error("Expected a numeric identifier"); + }} + onUploadError={onUploadError} + onUploadEnd={onUploadEnd} + />, + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["contents"], "invalid-response.ttl")] }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); + await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "not-a-number"); + + await waitFor(() => + expect(onUploadError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "response", + status: 200, + file: expect.objectContaining({ name: "invalid-response.ttl" }), + }), + ), + ); + expect(screen.getByRole("alert")).toHaveTextContent("Invalid response: Expected a numeric identifier"); + await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); + }); + + it("classifies an exhausted request as a transport error and ends the batch once", async () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + const onUploadError = jest.fn(); + const onUploadEnd = jest.fn(); + render( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["contents"], "network-error.ttl")] }, + }); + + for (let attempt = 0; attempt < 4; attempt += 1) { + await waitFor(() => expect(ControlledXMLHttpRequest.requests[attempt]?.sent).toBe(true)); + await failRequest(ControlledXMLHttpRequest.requests[attempt], "Connection lost"); + } + + await waitFor(() => + expect(onUploadError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "transport", + file: expect.objectContaining({ name: "network-error.ttl" }), + }), + ), + ); + expect(screen.getByRole("alert")).toHaveTextContent("Upload failed:"); + await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); + }); + + it("aborts an active request and ends the batch once without reporting an error", async () => { + const uploadRef = React.createRef(); + const onUploadError = jest.fn(); + const onUploadEnd = jest.fn(); + render( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["contents"], "cancel.ttl")] }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); + expect(screen.getByRole("group", { name: "Cancelable upload" })).toHaveAttribute("aria-busy", "true"); + + act(() => { + uploadRef.current!.cancel(); + uploadRef.current!.cancel(); + }); + + expect(ControlledXMLHttpRequest.requests[0].aborted).toBe(true); + expect(onUploadError).not.toHaveBeenCalled(); + expect(onUploadEnd).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Cancelable upload" })).not.toHaveAttribute("aria-busy"); + }); + + it("resets an active upload idempotently and accepts a fresh selection", async () => { + const uploadRef = React.createRef(); + const onUploadEnd = jest.fn(); + render( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["first"], "first.ttl")] }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); + act(() => ControlledXMLHttpRequest.requests[0].progress(1, 5)); + + act(() => { + uploadRef.current!.reset(); + uploadRef.current!.reset(); + }); + + expect(ControlledXMLHttpRequest.requests[0].aborted).toBe(true); + expect(onUploadEnd).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["second"], "second.ttl")] }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[1]?.sent).toBe(true)); + }); + + it("aborts on unmount without firing late consumer callbacks", async () => { + const onUploadEnd = jest.fn(); + const onUploadSuccess = jest.fn(); + const { unmount } = render( + , + ); + fireEvent.change(document.querySelector("input[type=file]")!, { + target: { files: [new File(["contents"], "unmount.ttl")] }, + }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); + + unmount(); + + expect(ControlledXMLHttpRequest.requests[0].aborted).toBe(true); + expect(onUploadSuccess).not.toHaveBeenCalled(); + expect(onUploadEnd).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index fba8cbe2..4d08ec8c 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -2,9 +2,37 @@ import React from "react"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; import Icon from "../Icon/Icon"; +import ProgressBar from "../ProgressBar/ProgressBar"; -import { FileUploadError, FileUploadFile, FileUploadHandle, FileUploadProps } from "./types"; -import { HeadlessUppyFile, Uppy, UppyContextProvider, useDropzone, useFileInput } from "./uppyHeadless"; +import { + FileUploadError, + FileUploadFile, + FileUploadHandle, + FileUploadProps, + FileUploadResponseMetadata, +} from "./types"; +import { + HeadlessUppyFile, + HeadlessUploadResponse, + Uppy, + UppyContextProvider, + useDropzone, + useFileInput, + XHRUpload, +} from "./uppyHeadless"; + +class ResponseParseError extends Error { + readonly status: number; + + constructor(error: Error, status: number) { + super(error.message, { cause: error }); + this.name = "ResponseParseError"; + this.status = status; + } +} + +const asError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); +const percentage = (value: number): number => Math.max(0, Math.min(100, Math.round(value))); const publicFile = (file: { id: string; name?: string; type?: string; size?: number | null }): FileUploadFile => ({ id: file.id, @@ -94,10 +122,19 @@ function FileUploadInner( hideName = false, labels, instructions, + endpoint, acceptedFileTypes, maxFileSize, maxNumberOfFiles = 1, + autoUpload = true, + method = "POST", + headers, + parseResponse, + onUploadStart, + onUploadProgress, + onUploadSuccess, onUploadError, + onUploadEnd, disabled = false, }: FileUploadProps, ref: React.ForwardedRef, @@ -107,30 +144,88 @@ function FileUploadInner( const labelId = `${widgetId}-label`; const instructionsId = `${widgetId}-instructions`; const errorId = `${widgetId}-error`; + const progressId = `${widgetId}-progress`; const [selectionStatus, setSelectionStatus] = React.useState(); - const [restrictionError, setRestrictionError] = React.useState(); - const labelsRef = React.useRef(labels); - const onUploadErrorRef = React.useRef(onUploadError); - labelsRef.current = labels; - onUploadErrorRef.current = onUploadError; - const [uppy] = React.useState( - () => - new Uppy({ - id: widgetId, - autoProceed: false, - restrictions: { allowedFileTypes: acceptedFileTypes, maxFileSize, maxNumberOfFiles }, - }), + const [inlineError, setInlineError] = React.useState(); + const [uploadState, setUploadState] = React.useState({ + active: false, + completed: 0, + progress: 0, + total: 0, + visible: false, + }); + const activeBatchRef = React.useRef(false); + const progressRef = React.useRef(0); + const uploadPromiseRef = React.useRef>(); + const propsRef = React.useRef({ + endpoint, + headers, + labels, + onUploadEnd, + onUploadError, + onUploadProgress, + onUploadStart, + onUploadSuccess, + parseResponse, + }); + propsRef.current = { + endpoint, + headers, + labels, + onUploadEnd, + onUploadError, + onUploadProgress, + onUploadStart, + onUploadSuccess, + parseResponse, + }; + const [uppy] = React.useState(() => + new Uppy({ + id: widgetId, + autoProceed: autoUpload, + restrictions: { allowedFileTypes: acceptedFileTypes, maxFileSize, maxNumberOfFiles }, + }).use(XHRUpload, { + endpoint: (file) => { + const currentEndpoint = propsRef.current.endpoint; + return typeof currentEndpoint === "function" ? currentEndpoint(publicFile(file)) : currentEndpoint; + }, + getResponseData: (xhr) => { + const metadata: FileUploadResponseMetadata = { + responseText: xhr.responseText, + status: xhr.status, + }; + try { + return propsRef.current.parseResponse?.(metadata) ?? metadata.responseText; + } catch (error) { + throw new ResponseParseError(asError(error), xhr.status); + } + }, + headers: () => { + const currentHeaders = propsRef.current.headers; + return typeof currentHeaders === "function" ? currentHeaders() : (currentHeaders ?? {}); + }, + method, + }), ); React.useEffect(() => { - uppy.setOptions({ restrictions: { allowedFileTypes: acceptedFileTypes, maxFileSize, maxNumberOfFiles } }); - }, [acceptedFileTypes, maxFileSize, maxNumberOfFiles, uppy]); + uppy.setOptions({ + autoProceed: autoUpload, + restrictions: { allowedFileTypes: acceptedFileTypes, maxFileSize, maxNumberOfFiles }, + }); + uppy.getPlugin("XHRUpload")?.setOptions({ method }); + }, [acceptedFileTypes, autoUpload, maxFileSize, maxNumberOfFiles, method, uppy]); React.useEffect(() => { + const endBatch = () => { + if (!activeBatchRef.current) return; + activeBatchRef.current = false; + propsRef.current.onUploadEnd?.(); + }; const handleFileAdded = (file: HeadlessUppyFile) => { const selectedFile = publicFile(file); - setRestrictionError(undefined); - setSelectionStatus(labelsRef.current.selectedFile?.(selectedFile) ?? selectedFile.name); + setInlineError(undefined); + setSelectionStatus(propsRef.current.labels.selectedFile?.(selectedFile) ?? selectedFile.name); }; const handleRestrictionFailed = (file: HeadlessUppyFile | undefined, error: Error) => { const rejectedFile = file ? publicFile(file) : undefined; @@ -139,43 +234,131 @@ function FileUploadInner( error, ...(rejectedFile ? { file: rejectedFile } : {}), }; - setRestrictionError(labelsRef.current.restrictionError?.(error, rejectedFile) ?? error.message); - onUploadErrorRef.current?.(uploadError); + setInlineError(propsRef.current.labels.restrictionError?.(error, rejectedFile) ?? error.message); + propsRef.current.onUploadError?.(uploadError); + }; + const handleUpload = (_uploadId: string, files: Record) => { + activeBatchRef.current = true; + progressRef.current = 0; + setInlineError(undefined); + setUploadState({ + active: true, + completed: 0, + progress: 0, + total: Object.keys(files).length, + visible: true, + }); + propsRef.current.onUploadStart?.(); + }; + const handleProgress = (progress: number) => { + const currentProgress = percentage(progress); + progressRef.current = currentProgress; + setUploadState((state) => ({ ...state, progress: currentProgress })); + propsRef.current.onUploadProgress?.(currentProgress); + }; + const handleUploadSuccess = (file: HeadlessUppyFile | undefined, response: HeadlessUploadResponse) => { + if (!file || !activeBatchRef.current) return; + const uploadedFile = publicFile(file); + setUploadState((state) => ({ ...state, completed: Math.min(state.total, state.completed + 1) })); + setSelectionStatus(propsRef.current.labels.uploadedFile?.(uploadedFile) ?? uploadedFile.name); + // The public parser contract guarantees T; the compatibility adapter intentionally keeps Uppy body types internal. + propsRef.current.onUploadSuccess?.({ + body: response.body as T, + file: uploadedFile, + status: response.status, + }); + }; + const handleUploadError = (file: HeadlessUppyFile | undefined, error: Error, response?: XMLHttpRequest) => { + if (!activeBatchRef.current) return; + const failedFile = file ? publicFile(file) : undefined; + const responseFailure = error instanceof ResponseParseError; + const uploadError: FileUploadError = { + error, + kind: responseFailure ? "response" : "transport", + ...(failedFile ? { file: failedFile } : {}), + ...(responseFailure || response?.status + ? { status: responseFailure ? error.status : response?.status } + : {}), + }; + const formatError = responseFailure + ? propsRef.current.labels.responseError + : propsRef.current.labels.transportError; + setInlineError(formatError?.(error, failedFile) ?? error.message); + propsRef.current.onUploadError?.(uploadError); + }; + const handleComplete = (result: { failed: HeadlessUppyFile[] }) => { + if (!activeBatchRef.current) return; + const completedProgress = result.failed.length === 0 ? 100 : progressRef.current; + setUploadState((state) => ({ ...state, active: false, progress: completedProgress })); + if (completedProgress !== progressRef.current) { + progressRef.current = completedProgress; + propsRef.current.onUploadProgress?.(completedProgress); + } + endBatch(); + }; + const handleCancelAll = () => { + progressRef.current = 0; + setSelectionStatus(undefined); + setUploadState({ active: false, completed: 0, progress: 0, total: 0, visible: false }); + endBatch(); }; uppy.on("file-added", handleFileAdded); uppy.on("restriction-failed", handleRestrictionFailed); + uppy.on("upload", handleUpload); + uppy.on("progress", handleProgress); + uppy.on("upload-success", handleUploadSuccess); + uppy.on("upload-error", handleUploadError); + uppy.on("complete", handleComplete); + uppy.on("cancel-all", handleCancelAll); return () => { uppy.off("file-added", handleFileAdded); uppy.off("restriction-failed", handleRestrictionFailed); + uppy.off("upload", handleUpload); + uppy.off("progress", handleProgress); + uppy.off("upload-success", handleUploadSuccess); + uppy.off("upload-error", handleUploadError); + uppy.off("complete", handleComplete); + uppy.off("cancel-all", handleCancelAll); + uppy.destroy(); }; }, [uppy]); - React.useEffect(() => () => uppy.destroy(), [uppy]); - React.useImperativeHandle( ref, () => ({ - upload: async () => { - await uppy.upload(); + upload: () => { + if (disabled) return Promise.resolve(); + if (uploadPromiseRef.current) return uploadPromiseRef.current; + const uploadPromise = uppy + .upload() + .then(() => undefined) + .finally(() => { + if (uploadPromiseRef.current === uploadPromise) uploadPromiseRef.current = undefined; + }); + uploadPromiseRef.current = uploadPromise; + return uploadPromise; }, cancel: () => uppy.cancelAll(), reset: () => { uppy.cancelAll(); setSelectionStatus(undefined); - setRestrictionError(undefined); + setInlineError(undefined); }, }), - [uppy], + [disabled, uppy], ); - const descriptionIds = [instructions ? instructionsId : undefined, restrictionError ? errorId : undefined] + const descriptionIds = [instructions ? instructionsId : undefined, inlineError ? errorId : undefined] .filter(Boolean) .join(" "); + const multipleFiles = uploadState.total > 1; + const progressLabel = multipleFiles ? labels.overallUploadProgress : labels.uploadProgress; return (
( + {uploadState.visible && ( +
+
+ {progressLabel} + +
+
+
+ {multipleFiles && ( +
+ {labels.completedFiles(uploadState.completed, uploadState.total)} +
+ )} +
+ )} {instructions && (
{instructions}
)} - {restrictionError && ( + {inlineError && ( )} {selectionStatus &&
{selectionStatus}
} diff --git a/src/components/FileUpload/fileupload.scss b/src/components/FileUpload/fileupload.scss index 2c76f741..b4505c7b 100644 --- a/src/components/FileUpload/fileupload.scss +++ b/src/components/FileUpload/fileupload.scss @@ -60,6 +60,22 @@ color: eccgui-color-rgba($eccgui-color-applicationheader-text, $eccgui-opacity-muted); } +.#{$eccgui}-fileupload__progress { + display: grid; + gap: 0.5 * $eccgui-size-inline-whitespace; +} + +.#{$eccgui}-fileupload__progress-header { + display: flex; + justify-content: space-between; + font-weight: $eccgui-font-weight-bold; +} + +.#{$eccgui}-fileupload__completed-files { + font-size: $eccgui-size-typo-caption; + color: eccgui-color-rgba($eccgui-color-applicationheader-text, $eccgui-opacity-muted); +} + .#{$eccgui}-fileupload__error { color: $eccgui-color-danger-text; } diff --git a/src/components/FileUpload/types.ts b/src/components/FileUpload/types.ts index 44235986..ae0b9cfe 100644 --- a/src/components/FileUpload/types.ts +++ b/src/components/FileUpload/types.ts @@ -17,7 +17,7 @@ export interface FileUploadResponse { file: FileUploadFile; } -export type FileUploadErrorKind = "restriction" | "response" | "transport" | "cancelled"; +export type FileUploadErrorKind = "restriction" | "response" | "transport"; export interface FileUploadError { kind: FileUploadErrorKind; @@ -29,8 +29,14 @@ export interface FileUploadError { export interface FileUploadLabels { dropHereOr: string; browse: string; + uploadProgress: string; + overallUploadProgress: string; + completedFiles: (completed: number, total: number) => string; selectedFile?: (file: FileUploadFile) => string; + uploadedFile?: (file: FileUploadFile) => string; restrictionError?: (error: Error, file?: FileUploadFile) => string; + responseError?: (error: Error, file?: FileUploadFile) => string; + transportError?: (error: Error, file?: FileUploadFile) => string; } export interface FileUploadResponseMetadata { @@ -41,7 +47,7 @@ export interface FileUploadResponseMetadata { export type FileUploadEndpoint = string | ((file: FileUploadFile) => string); export type FileUploadHeaders = Record | (() => Record); -export interface FileUploadProps { +interface FileUploadBaseProps { /** Stable ID for the widget. A unique ID is generated when omitted. */ id?: string; /** Localized accessible name, displayed as the widget label by default. */ @@ -59,7 +65,6 @@ export interface FileUploadProps { autoUpload?: boolean; method?: "POST" | "PUT"; headers?: FileUploadHeaders; - parseResponse?: (metadata: FileUploadResponseMetadata) => T; onUploadStart?: () => void; onUploadProgress?: (percentage: number) => void; onUploadSuccess?: (response: FileUploadResponse) => void; @@ -67,3 +72,9 @@ export interface FileUploadProps { onUploadEnd?: () => void; disabled?: boolean; } + +type FileUploadParserProps = [T] extends [string] + ? { parseResponse?: (metadata: FileUploadResponseMetadata) => T } + : { parseResponse: (metadata: FileUploadResponseMetadata) => T }; + +export type FileUploadProps = FileUploadBaseProps & FileUploadParserProps; diff --git a/src/components/FileUpload/uppyHeadless.ts b/src/components/FileUpload/uppyHeadless.ts index 7546e985..4ff10cb5 100644 --- a/src/components/FileUpload/uppyHeadless.ts +++ b/src/components/FileUpload/uppyHeadless.ts @@ -1,6 +1,7 @@ import React from "react"; import UppyCore from "@uppy/core"; import * as UppyReact from "@uppy/react"; +import XHRUploadCore from "@uppy/xhr-upload"; export interface HeadlessUppyFile { id: string; @@ -21,15 +22,42 @@ interface HeadlessUppyOptions { restrictions: HeadlessUppyRestrictions; } +export interface HeadlessUploadResponse { + body: unknown; + status: number; +} + +interface HeadlessUppyEvents { + "cancel-all": () => void; + complete: (result: { failed: HeadlessUppyFile[]; successful: HeadlessUppyFile[] }) => void; + "file-added": (file: HeadlessUppyFile) => void; + progress: (percentage: number) => void; + "restriction-failed": (file: HeadlessUppyFile | undefined, error: Error) => void; + upload: (uploadId: string, files: Record) => void; + "upload-error": (file: HeadlessUppyFile | undefined, error: Error, response?: XMLHttpRequest) => void; + "upload-success": (file: HeadlessUppyFile | undefined, response: HeadlessUploadResponse) => void; +} + +interface HeadlessXhrUploadOptions { + endpoint: (file: HeadlessUppyFile) => string; + getResponseData: (xhr: XMLHttpRequest) => unknown; + headers: (file: HeadlessUppyFile) => Record; + method: "POST" | "PUT"; +} + +interface HeadlessXhrUploadConstructor { + new (...args: unknown[]): unknown; +} + export interface HeadlessUppy { cancelAll(): void; destroy(): void; - off(event: "file-added", callback: (file: HeadlessUppyFile) => void): void; - off(event: "restriction-failed", callback: (file: HeadlessUppyFile | undefined, error: Error) => void): void; - on(event: "file-added", callback: (file: HeadlessUppyFile) => void): void; - on(event: "restriction-failed", callback: (file: HeadlessUppyFile | undefined, error: Error) => void): void; - setOptions(options: { restrictions: HeadlessUppyRestrictions }): void; + getPlugin(id: "XHRUpload"): { setOptions(options: Pick): void } | undefined; + off(event: Event, callback: HeadlessUppyEvents[Event]): void; + on(event: Event, callback: HeadlessUppyEvents[Event]): void; + setOptions(options: { autoProceed?: boolean; restrictions?: HeadlessUppyRestrictions }): void; upload(): Promise; + use(plugin: HeadlessXhrUploadConstructor, options: HeadlessXhrUploadOptions): HeadlessUppy; } interface HeadlessUppyConstructor { @@ -65,4 +93,5 @@ interface UppyReactHeadless { // Source consumers can still hoist legacy Uppy declarations during the staged migration. // Runtime package resolution remains on gui-elements' pinned Uppy 5 dependencies. export const Uppy = UppyCore as unknown as HeadlessUppyConstructor; +export const XHRUpload = XHRUploadCore as unknown as HeadlessXhrUploadConstructor; export const { UppyContextProvider, useDropzone, useFileInput } = UppyReact as unknown as UppyReactHeadless; diff --git a/yarn.lock b/yarn.lock index 0c3b6ff4..14ebdf41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4041,6 +4041,11 @@ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8" integrity sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ== +"@types/retry@0.12.2": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.2.tgz#ed279a64fa438bb69f2480eda44937912bb7480a" + integrity sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow== + "@types/semver@^7.7.1": version "7.7.1" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" @@ -4276,6 +4281,15 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== +"@uppy/companion-client@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@uppy/companion-client/-/companion-client-5.1.1.tgz#5bfeda3312ba2b70e94d4fd73a31d0f7757884c0" + integrity sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q== + dependencies: + "@uppy/utils" "^7.1.1" + namespace-emitter "^2.0.1" + p-retry "^6.1.0" + "@uppy/components@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@uppy/components/-/components-1.2.0.tgz#20d815ca7920f8d875cb40b942a7e2878fc19ea8" @@ -4314,7 +4328,7 @@ resolved "https://registry.yarnpkg.com/@uppy/store-default/-/store-default-5.0.0.tgz#2466162857a999b8c99051426e5f412e2f34cb26" integrity sha512-hQtCSQ1yGiaval/wVYUWquYGDJ+bpQ7e4FhUUAsRQz1x1K+o7NBtjfp63O9I4Ks1WRoKunpkarZ+as09l02cPw== -"@uppy/utils@^7.1.4": +"@uppy/utils@^7.1.1", "@uppy/utils@^7.1.4", "@uppy/utils@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@uppy/utils/-/utils-7.2.0.tgz#10212e92d8b57ff9d854276d056e98fdcd4643c9" integrity sha512-6lC246qszMv6bTyl/+QyHwrudgeguWkA94ME1wHn+a6uRAvmtAEaUManIfGqTJfoKvWAiCJqdJPl5xRJjhAloQ== @@ -4322,6 +4336,14 @@ lodash "^4.17.23" preact "^10.26.10" +"@uppy/xhr-upload@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@uppy/xhr-upload/-/xhr-upload-5.2.0.tgz#f8a6b98bad3ec7678433dc6bfa0b4b5c6855652c" + integrity sha512-3LV/X5Of6BINnKplP+CwUJ0a4/7cRFfzxwGyXnW+uCrNQHoo09dttcz3begWHejGvzenQHuUnMO3Fxyc71Pryg== + dependencies: + "@uppy/companion-client" "^5.1.1" + "@uppy/utils" "^7.2.0" + "@vitest/expect@3.2.4": version "3.2.4" resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" @@ -7845,6 +7867,11 @@ is-nan@^1.3.2: call-bind "^1.0.0" define-properties "^1.1.3" +is-network-error@^1.0.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.3.2.tgz#9460bc30f8419a4bca77114f4de88a3ee5e0c519" + integrity sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA== + is-number-object@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" @@ -9905,6 +9932,15 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-retry@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-6.2.1.tgz#81828f8dc61c6ef5a800585491572cc9892703af" + integrity sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ== + dependencies: + "@types/retry" "0.12.2" + is-network-error "^1.0.0" + retry "^0.13.1" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -10894,6 +10930,11 @@ restore-cursor@^5.0.0: onetime "^7.0.0" signal-exit "^4.1.0" +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.4: version "1.1.0" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" From 4099bf26a5d916e4246028d944acaa2fc660bcd0 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Wed, 16 Sep 2026 12:31:34 +0200 Subject: [PATCH 05/12] In addition to overall upload progress also show single file progress --- CHANGELOG.md | 2 +- .../FileUpload/FileUpload.stories.tsx | 1 + src/components/FileUpload/FileUpload.test.tsx | 1 + .../FileUpload/FileUpload.transport.test.tsx | 26 ++++- src/components/FileUpload/FileUpload.tsx | 103 +++++++++++++++--- src/components/FileUpload/fileupload.scss | 22 ++++ src/components/FileUpload/types.ts | 1 + src/components/FileUpload/uppyHeadless.ts | 8 +- 8 files changed, 143 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70dc4fb1..d3977a92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - accessible native file picker and drag-and-drop selection based on Uppy 5 headless hooks - accepted file type, maximum file size/count and disabled-state restrictions - automatic or imperative XHR uploads with current endpoints, headers and typed response parsing - - aggregate upload progress, per-file success and batch lifecycle callbacks + - per-file upload progress, multi-file aggregate progress, per-file success and batch lifecycle callbacks - idempotent cancellation/reset and active-request teardown on unmount - localized selection, success and inline restriction/transport/response-error text - accessible busy, progress, alert and polite status semantics diff --git a/src/components/FileUpload/FileUpload.stories.tsx b/src/components/FileUpload/FileUpload.stories.tsx index 85eaa338..ece7b138 100644 --- a/src/components/FileUpload/FileUpload.stories.tsx +++ b/src/components/FileUpload/FileUpload.stories.tsx @@ -14,6 +14,7 @@ const defaultArgs: FileUploadProps = { uploadProgress: "Upload progress", overallUploadProgress: "Overall upload progress", completedFiles: (completed, total) => `${completed} of ${total} files completed`, + fileUploadProgress: (file) => `Upload progress for ${file.name}`, selectedFile: (file) => `Selected ${file.name}`, }, instructions: "Turtle, N-Triples or RDF/XML; maximum 10 MB. Press Enter or Space to browse.", diff --git a/src/components/FileUpload/FileUpload.test.tsx b/src/components/FileUpload/FileUpload.test.tsx index 1d25d1a2..f5dc0e62 100644 --- a/src/components/FileUpload/FileUpload.test.tsx +++ b/src/components/FileUpload/FileUpload.test.tsx @@ -14,6 +14,7 @@ const labels = { uploadProgress: "Upload progress", overallUploadProgress: "Overall upload progress", completedFiles: (completed: number, total: number) => `${completed} of ${total} files completed`, + fileUploadProgress: (file: { name: string }) => `Upload progress for ${file.name}`, }; const renderFileUpload = (props: Partial> = {}) => diff --git a/src/components/FileUpload/FileUpload.transport.test.tsx b/src/components/FileUpload/FileUpload.transport.test.tsx index aca692d5..19323bfd 100644 --- a/src/components/FileUpload/FileUpload.transport.test.tsx +++ b/src/components/FileUpload/FileUpload.transport.test.tsx @@ -10,6 +10,7 @@ const labels = { browse: "browse files", completedFiles: (completed: number, total: number) => `${completed} of ${total} files completed`, dropHereOr: "Drop files here or", + fileUploadProgress: (file: { name: string }) => `Upload progress for ${file.name}`, overallUploadProgress: "Overall upload progress", responseError: (error: Error) => `Invalid response: ${error.message}`, transportError: (error: Error) => `Upload failed: ${error.message}`, @@ -157,7 +158,11 @@ describe("FileUpload transport", () => { expect(request.requestHeaders).toEqual({ Authorization: "current token" }); expect(endpoint).toHaveBeenCalledWith(expect.objectContaining({ name: "vocabulary.ttl" })); act(() => request.progress(4, 8)); - expect(screen.getByRole("progressbar", { name: "Upload progress" })).toHaveAttribute("aria-valuenow", "50"); + expect(screen.getByRole("progressbar", { name: "Upload progress for vocabulary.ttl" })).toHaveAttribute( + "aria-valuenow", + "50", + ); + expect(screen.queryByRole("progressbar", { name: "Overall upload progress" })).not.toBeInTheDocument(); await completeRequest(request, 201, "upload-id"); await waitFor(() => expect(onUploadSuccess).toHaveBeenCalled()); @@ -218,10 +223,26 @@ describe("FileUpload transport", () => { "aria-valuenow", "25", ); + expect(screen.getByRole("progressbar", { name: "Upload progress for first.ttl" })).toHaveAttribute( + "aria-valuenow", + "50", + ); + expect(screen.getByRole("progressbar", { name: "Upload progress for second.ttl" })).toHaveAttribute( + "aria-valuenow", + "0", + ); + expect(screen.getAllByRole("progressbar")).toHaveLength(3); expect(screen.getByText("0 of 2 files completed")).toBeInTheDocument(); await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "first"); await waitFor(() => expect(screen.getByText("1 of 2 files completed")).toBeInTheDocument()); + expect(screen.getByRole("progressbar", { name: "Upload progress for first.ttl" })).toHaveAttribute( + "aria-valuenow", + "100", + ); + expect( + screen.getByRole("progressbar", { name: "Upload progress for first.ttl" }).closest("[role=listitem]"), + ).toHaveAttribute("data-state", "complete"); expect(onUploadEnd).not.toHaveBeenCalled(); await completeRequest(ControlledXMLHttpRequest.requests[1], 200, "second"); await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); @@ -260,6 +281,9 @@ describe("FileUpload transport", () => { expect(onUploadError).toHaveBeenCalledWith( expect.objectContaining({ kind: "transport", file: expect.objectContaining({ name: "second.ttl" }) }), ); + expect( + screen.getByRole("progressbar", { name: "Upload progress for second.ttl" }).closest("[role=listitem]"), + ).toHaveAttribute("data-state", "error"); expect(onUploadEnd).toHaveBeenCalledTimes(1); }); diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index 4d08ec8c..a30e8435 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -12,6 +12,7 @@ import { FileUploadResponseMetadata, } from "./types"; import { + HeadlessFileProgress, HeadlessUppyFile, HeadlessUploadResponse, Uppy, @@ -21,6 +22,12 @@ import { XHRUpload, } from "./uppyHeadless"; +interface UploadFileState { + file: FileUploadFile; + progress: number; + status: "uploading" | "complete" | "error"; +} + class ResponseParseError extends Error { readonly status: number; @@ -147,6 +154,7 @@ function FileUploadInner( const progressId = `${widgetId}-progress`; const [selectionStatus, setSelectionStatus] = React.useState(); const [inlineError, setInlineError] = React.useState(); + const [uploadFiles, setUploadFiles] = React.useState([]); const [uploadState, setUploadState] = React.useState({ active: false, completed: 0, @@ -237,15 +245,22 @@ function FileUploadInner( setInlineError(propsRef.current.labels.restrictionError?.(error, rejectedFile) ?? error.message); propsRef.current.onUploadError?.(uploadError); }; - const handleUpload = (_uploadId: string, files: Record) => { + const handleUpload = (_uploadId: string, files: HeadlessUppyFile[]) => { activeBatchRef.current = true; progressRef.current = 0; setInlineError(undefined); + setUploadFiles( + files.map((file) => ({ + file: publicFile(file), + progress: 0, + status: "uploading", + })), + ); setUploadState({ active: true, completed: 0, progress: 0, - total: Object.keys(files).length, + total: files.length, visible: true, }); propsRef.current.onUploadStart?.(); @@ -256,10 +271,27 @@ function FileUploadInner( setUploadState((state) => ({ ...state, progress: currentProgress })); propsRef.current.onUploadProgress?.(currentProgress); }; + const handleFileProgress = (file: HeadlessUppyFile | undefined, progress: HeadlessFileProgress) => { + if (!file || !activeBatchRef.current) return; + const bytesTotal = progress.bytesTotal ?? file.size; + const currentProgress = bytesTotal ? percentage((progress.bytesUploaded / bytesTotal) * 100) : 0; + setUploadFiles((currentFiles) => + currentFiles.map((currentFile) => + currentFile.file.id === file.id ? { ...currentFile, progress: currentProgress } : currentFile, + ), + ); + }; const handleUploadSuccess = (file: HeadlessUppyFile | undefined, response: HeadlessUploadResponse) => { if (!file || !activeBatchRef.current) return; const uploadedFile = publicFile(file); setUploadState((state) => ({ ...state, completed: Math.min(state.total, state.completed + 1) })); + setUploadFiles((currentFiles) => + currentFiles.map((currentFile) => + currentFile.file.id === file.id + ? { ...currentFile, progress: 100, status: "complete" } + : currentFile, + ), + ); setSelectionStatus(propsRef.current.labels.uploadedFile?.(uploadedFile) ?? uploadedFile.name); // The public parser contract guarantees T; the compatibility adapter intentionally keeps Uppy body types internal. propsRef.current.onUploadSuccess?.({ @@ -284,6 +316,13 @@ function FileUploadInner( ? propsRef.current.labels.responseError : propsRef.current.labels.transportError; setInlineError(formatError?.(error, failedFile) ?? error.message); + if (file) { + setUploadFiles((currentFiles) => + currentFiles.map((currentFile) => + currentFile.file.id === file.id ? { ...currentFile, status: "error" } : currentFile, + ), + ); + } propsRef.current.onUploadError?.(uploadError); }; const handleComplete = (result: { failed: HeadlessUppyFile[] }) => { @@ -299,6 +338,7 @@ function FileUploadInner( const handleCancelAll = () => { progressRef.current = 0; setSelectionStatus(undefined); + setUploadFiles([]); setUploadState({ active: false, completed: 0, progress: 0, total: 0, visible: false }); endBatch(); }; @@ -307,6 +347,7 @@ function FileUploadInner( uppy.on("restriction-failed", handleRestrictionFailed); uppy.on("upload", handleUpload); uppy.on("progress", handleProgress); + uppy.on("upload-progress", handleFileProgress); uppy.on("upload-success", handleUploadSuccess); uppy.on("upload-error", handleUploadError); uppy.on("complete", handleComplete); @@ -316,6 +357,7 @@ function FileUploadInner( uppy.off("restriction-failed", handleRestrictionFailed); uppy.off("upload", handleUpload); uppy.off("progress", handleProgress); + uppy.off("upload-progress", handleFileProgress); uppy.off("upload-success", handleUploadSuccess); uppy.off("upload-error", handleUploadError); uppy.off("complete", handleComplete); @@ -353,7 +395,6 @@ function FileUploadInner( .filter(Boolean) .join(" "); const multipleFiles = uploadState.total > 1; - const progressLabel = multipleFiles ? labels.overallUploadProgress : labels.uploadProgress; return (
( {uploadState.visible && (
-
- {progressLabel} - -
-
-
{multipleFiles && ( -
- {labels.completedFiles(uploadState.completed, uploadState.total)} +
+
+ {labels.overallUploadProgress} + +
+
+
+
+ {labels.completedFiles(uploadState.completed, uploadState.total)} +
)} +
+ {uploadFiles.map((uploadFile) => ( +
+
+ {uploadFile.file.name} + +
+
+
+
+ ))} +
)} {instructions && ( diff --git a/src/components/FileUpload/fileupload.scss b/src/components/FileUpload/fileupload.scss index b4505c7b..3337802b 100644 --- a/src/components/FileUpload/fileupload.scss +++ b/src/components/FileUpload/fileupload.scss @@ -61,14 +61,36 @@ } .#{$eccgui}-fileupload__progress { + display: grid; + gap: $eccgui-size-inline-whitespace; +} + +.#{$eccgui}-fileupload__overall-progress, +.#{$eccgui}-fileupload__file-list, +.#{$eccgui}-fileupload__file { display: grid; gap: 0.5 * $eccgui-size-inline-whitespace; } +.#{$eccgui}-fileupload__overall-progress { + padding-bottom: $eccgui-size-inline-whitespace; + border-bottom: 1px solid $pt-divider-black; +} + +.#{$eccgui}-fileupload__file + .#{$eccgui}-fileupload__file { + padding-top: $eccgui-size-inline-whitespace; + border-top: 1px solid $pt-divider-black; +} + .#{$eccgui}-fileupload__progress-header { display: flex; + gap: $eccgui-size-inline-whitespace; justify-content: space-between; font-weight: $eccgui-font-weight-bold; + + > :first-child { + overflow-wrap: anywhere; + } } .#{$eccgui}-fileupload__completed-files { diff --git a/src/components/FileUpload/types.ts b/src/components/FileUpload/types.ts index ae0b9cfe..5ef74efc 100644 --- a/src/components/FileUpload/types.ts +++ b/src/components/FileUpload/types.ts @@ -31,6 +31,7 @@ export interface FileUploadLabels { browse: string; uploadProgress: string; overallUploadProgress: string; + fileUploadProgress: (file: FileUploadFile) => string; completedFiles: (completed: number, total: number) => string; selectedFile?: (file: FileUploadFile) => string; uploadedFile?: (file: FileUploadFile) => string; diff --git a/src/components/FileUpload/uppyHeadless.ts b/src/components/FileUpload/uppyHeadless.ts index 4ff10cb5..ddd67d95 100644 --- a/src/components/FileUpload/uppyHeadless.ts +++ b/src/components/FileUpload/uppyHeadless.ts @@ -27,14 +27,20 @@ export interface HeadlessUploadResponse { status: number; } +export interface HeadlessFileProgress { + bytesTotal: number | null; + bytesUploaded: number; +} + interface HeadlessUppyEvents { "cancel-all": () => void; complete: (result: { failed: HeadlessUppyFile[]; successful: HeadlessUppyFile[] }) => void; "file-added": (file: HeadlessUppyFile) => void; progress: (percentage: number) => void; "restriction-failed": (file: HeadlessUppyFile | undefined, error: Error) => void; - upload: (uploadId: string, files: Record) => void; + upload: (uploadId: string, files: HeadlessUppyFile[]) => void; "upload-error": (file: HeadlessUppyFile | undefined, error: Error, response?: XMLHttpRequest) => void; + "upload-progress": (file: HeadlessUppyFile | undefined, progress: HeadlessFileProgress) => void; "upload-success": (file: HeadlessUppyFile | undefined, response: HeadlessUploadResponse) => void; } From 3f1fa44a62860f2884e771d17a0ef1b674e7898a Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Wed, 16 Sep 2026 14:46:14 +0200 Subject: [PATCH 06/12] Add safe and clear multi-file upload handling - upload files sequentially by default and allow explicit concurrency - show aggregate and per-file progress with static success and error states - add deterministic Storybook scenarios for upload lifecycle states - verify FileUpload through built ESM and CommonJS package exports - retain the original parser error and HTTP status in response failures --- CHANGELOG.md | 5 +- scripts/test-package-exports-commonjs.cjs | 18 ++ scripts/test-package-exports-esm.mjs | 18 ++ .../FileUpload/FileUpload.stories.tsx | 198 +++++++++++++++++- .../FileUpload/FileUpload.transport.test.tsx | 12 +- src/components/FileUpload/FileUpload.tsx | 20 +- src/components/FileUpload/types.ts | 2 + src/components/FileUpload/uppyHeadless.ts | 1 + 8 files changed, 263 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3977a92..d77c2d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,16 +8,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added -- Package smoke tests for the built ESM and CommonJS root exports +- Package smoke tests that render `` from the built ESM and CommonJS root exports - `` - accessible native file picker and drag-and-drop selection based on Uppy 5 headless hooks - accepted file type, maximum file size/count and disabled-state restrictions - - automatic or imperative XHR uploads with current endpoints, headers and typed response parsing + - automatic or imperative XHR uploads with current endpoints, headers, typed response parsing and configurable concurrency that defaults to sequential uploads - per-file upload progress, multi-file aggregate progress, per-file success and batch lifecycle callbacks - idempotent cancellation/reset and active-request teardown on unmount - localized selection, success and inline restriction/transport/response-error text - accessible busy, progress, alert and polite status semantics - optional integration with `ApplicationContainer` file-drop monitoring + - deterministic Storybook states for uploading, completion, mixed results and errors - `` - `noDrag` parameter: Add the `nodrag` class to the Switch element. Default: `true` - `` diff --git a/scripts/test-package-exports-commonjs.cjs b/scripts/test-package-exports-commonjs.cjs index f612ba19..2e649e0a 100644 --- a/scripts/test-package-exports-commonjs.cjs +++ b/scripts/test-package-exports-commonjs.cjs @@ -1,5 +1,23 @@ const assert = require("node:assert/strict"); +const React = require("react"); +const { renderToStaticMarkup } = require("react-dom/server"); const guiElements = require("@eccenca/gui-elements"); assert.ok("Button" in guiElements, "The CommonJS root export must expose gui-elements components"); +assert.ok("FileUpload" in guiElements, "The CommonJS root export must expose FileUpload"); +const markup = renderToStaticMarkup( + React.createElement(guiElements.FileUpload, { + endpoint: "/upload", + labels: { + browse: "browse", + completedFiles: (completed, total) => `${completed}/${total}`, + dropHereOr: "Drop here or", + fileUploadProgress: (file) => `Upload progress for ${file.name}`, + overallUploadProgress: "Overall upload progress", + uploadProgress: "Files", + }, + name: "Package upload", + }), +); +assert.match(markup, /role="group"/, "The CommonJS FileUpload export must render"); diff --git a/scripts/test-package-exports-esm.mjs b/scripts/test-package-exports-esm.mjs index e6c5cf3d..c3fcefdc 100644 --- a/scripts/test-package-exports-esm.mjs +++ b/scripts/test-package-exports-esm.mjs @@ -1,5 +1,23 @@ import assert from "node:assert/strict"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import * as guiElements from "@eccenca/gui-elements"; assert.ok("Button" in guiElements, "The ESM root export must expose gui-elements components"); +assert.ok("FileUpload" in guiElements, "The ESM root export must expose FileUpload"); +const markup = renderToStaticMarkup( + React.createElement(guiElements.FileUpload, { + endpoint: "/upload", + labels: { + browse: "browse", + completedFiles: (completed, total) => `${completed}/${total}`, + dropHereOr: "Drop here or", + fileUploadProgress: (file) => `Upload progress for ${file.name}`, + overallUploadProgress: "Overall upload progress", + uploadProgress: "Files", + }, + name: "Package upload", + }), +); +assert.match(markup, /role="group"/, "The ESM FileUpload export must render"); diff --git a/src/components/FileUpload/FileUpload.stories.tsx b/src/components/FileUpload/FileUpload.stories.tsx index ece7b138..0b2b9f2f 100644 --- a/src/components/FileUpload/FileUpload.stories.tsx +++ b/src/components/FileUpload/FileUpload.stories.tsx @@ -1,7 +1,8 @@ import React from "react"; import { Meta, StoryFn } from "@storybook/react"; +import { waitFor, within } from "storybook/test"; -import { FileUpload, FileUploadProps } from "../../index"; +import { FileUpload, FileUploadFile, FileUploadProps } from "../../index"; const defaultArgs: FileUploadProps = { name: "Upload graph file", @@ -11,11 +12,15 @@ const defaultArgs: FileUploadProps = { labels: { dropHereOr: "Drop a graph file here or", browse: "browse files", - uploadProgress: "Upload progress", + uploadProgress: "Files", overallUploadProgress: "Overall upload progress", completedFiles: (completed, total) => `${completed} of ${total} files completed`, fileUploadProgress: (file) => `Upload progress for ${file.name}`, selectedFile: (file) => `Selected ${file.name}`, + uploadedFile: (file) => `${file.name} uploaded`, + restrictionError: (_error, file) => `${file?.name ?? "File"} cannot be uploaded`, + responseError: (error, file) => `${file?.name ?? "File"} returned an invalid response: ${error.message}`, + transportError: (error, file) => `${file?.name ?? "File"} could not be uploaded: ${error.message}`, }, instructions: "Turtle, N-Triples or RDF/XML; maximum 10 MB. Press Enter or Space to browse.", }; @@ -24,6 +29,9 @@ export default { title: "Forms/FileUpload", component: FileUpload, args: defaultArgs, + parameters: { + a11y: { test: "error" }, + }, } as Meta; const Template: StoryFn = (args) => ; @@ -53,7 +61,187 @@ Disabled.args = { disabled: true, }; -export const MultipleFiles = Template.bind({}); -MultipleFiles.args = { - maxNumberOfFiles: 3, +type RequestOutcome = "uploading" | "complete" | "error"; + +interface StoryUploadState { + progress: number; + outcome: RequestOutcome; +} + +const storyFile = (name: string) => new File([name.repeat(32)], name, { type: "text/turtle" }); + +const createStoryXMLHttpRequest = (stateForFile: (fileName: string) => StoryUploadState) => + class StoryXMLHttpRequest { + response: unknown; + responseText = ""; + responseType: XMLHttpRequestResponseType = ""; + status = 0; + statusText = ""; + withCredentials = false; + onerror: (() => void) | null = null; + onload: (() => void) | null = null; + upload = { onprogress: null as ((event: ProgressEvent) => void) | null }; + + private url = ""; + private timers: number[] = []; + + abort() { + this.timers.forEach((timer) => window.clearTimeout(timer)); + this.timers = []; + } + + open(_method: string, url: string) { + this.url = url; + } + + send() { + const fileName = decodeURIComponent(this.url.split("/").pop() ?? ""); + const state = stateForFile(fileName); + this.schedule(() => { + this.upload.onprogress?.({ + lengthComputable: true, + loaded: state.progress, + total: 100, + } as ProgressEvent); + }, 50); + if (state.outcome === "complete") { + this.schedule(() => { + this.status = 200; + this.responseText = `${fileName}-upload-id`; + this.onload?.(); + }, 100); + } else if (state.outcome === "error") { + this.schedule(() => { + this.status = 400; + this.statusText = "Upload rejected"; + this.onload?.(); + }, 100); + } + } + + setRequestHeader() {} + + private schedule(callback: () => void, delay: number) { + this.timers.push(window.setTimeout(callback, delay)); + } + }; + +interface UploadStateStoryProps { + args: FileUploadProps; + files: File[]; + stateForFile: (fileName: string) => StoryUploadState; +} + +const UploadStateStory = ({ args, files, stateForFile }: UploadStateStoryProps) => { + const [transportReady, setTransportReady] = React.useState(false); + const storyRef = React.useRef(null); + const selected = React.useRef(false); + + React.useEffect(() => { + const nativeXMLHttpRequest = window.XMLHttpRequest; + window.XMLHttpRequest = createStoryXMLHttpRequest(stateForFile) as unknown as typeof XMLHttpRequest; + setTransportReady(true); + return () => { + window.XMLHttpRequest = nativeXMLHttpRequest; + }; + }, [stateForFile]); + + React.useEffect(() => { + if (!transportReady || selected.current) return; + const input = storyRef.current?.querySelector("input[type=file]"); + if (!(input instanceof HTMLInputElement)) return; + selected.current = true; + Object.defineProperty(input, "files", { configurable: true, value: files }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, [files, transportReady]); + + return ( +
+ {transportReady && ( + `/storybook-upload/${encodeURIComponent(file.name)}`} + maxNumberOfFiles={Math.max(args.maxNumberOfFiles ?? 1, files.length)} + /> + )} +
+ ); +}; + +const uploadingState = () => ({ outcome: "uploading", progress: 45 }) as const; +const completedState = () => ({ outcome: "complete", progress: 100 }) as const; +const errorState = () => ({ outcome: "error", progress: 25 }) as const; + +const waitForProgress = async (canvasElement: HTMLElement, name: string, value: string) => { + await waitFor( + () => { + const progressbar = within(canvasElement).getByRole("progressbar", { name }); + if (progressbar.getAttribute("aria-valuenow") !== value) { + throw new Error(`Expected ${name} to reach ${value}%`); + } + }, + { timeout: 3_000 }, + ); +}; + +const waitForAlert = async (canvasElement: HTMLElement) => { + await waitFor(() => within(canvasElement).getByRole("alert"), { timeout: 3_000 }); +}; + +export const Uploading: StoryFn = (args) => ( + +); +Uploading.play = ({ canvasElement }) => waitForProgress(canvasElement, "Upload progress for graph.ttl", "45"); + +export const Completed: StoryFn = (args) => ( + +); +Completed.play = ({ canvasElement }) => waitForProgress(canvasElement, "Upload progress for graph.ttl", "100"); + +const multipleFiles = [storyFile("first.ttl"), storyFile("second.ttl"), storyFile("third.ttl")]; +const multipleUploadingState = (fileName: string): StoryUploadState => { + if (fileName === "first.ttl") return { outcome: "complete", progress: 100 }; + if (fileName === "second.ttl") return { outcome: "uploading", progress: 65 }; + return { outcome: "uploading", progress: 20 }; +}; + +export const MultipleFilesUploading: StoryFn = (args) => ( + +); +MultipleFilesUploading.args = { concurrency: 3 }; +MultipleFilesUploading.play = ({ canvasElement }) => + waitForProgress(canvasElement, "Upload progress for second.ttl", "65"); + +const mixedResultState = (fileName: string): StoryUploadState => { + if (fileName === "first.ttl") return { outcome: "complete", progress: 100 }; + if (fileName === "second.ttl") return { outcome: "uploading", progress: 65 }; + return { outcome: "error", progress: 30 }; }; + +export const MixedResults: StoryFn = (args) => ( + +); +MixedResults.args = { concurrency: 3 }; +MixedResults.play = async ({ canvasElement }) => { + await waitFor( + () => { + const failedProgress = within(canvasElement).getByRole("progressbar", { + name: "Upload progress for third.ttl", + }); + if (failedProgress.closest("[role=listitem]")?.getAttribute("data-state") !== "error") { + throw new Error("Expected third.ttl to reach the error state"); + } + }, + { timeout: 3_000 }, + ); +}; + +export const TransportError: StoryFn = (args) => ( + +); +TransportError.play = ({ canvasElement }) => waitForAlert(canvasElement); + +export const RestrictionError: StoryFn = (args) => ( + +); +RestrictionError.play = ({ canvasElement }) => waitForAlert(canvasElement); diff --git a/src/components/FileUpload/FileUpload.transport.test.tsx b/src/components/FileUpload/FileUpload.transport.test.tsx index 19323bfd..38714701 100644 --- a/src/components/FileUpload/FileUpload.transport.test.tsx +++ b/src/components/FileUpload/FileUpload.transport.test.tsx @@ -200,7 +200,7 @@ describe("FileUpload transport", () => { await firstUpload; }); - it("shows byte-aggregate progress and completion count only for multiple files", async () => { + it("uploads sequentially by default and shows aggregate and per-file progress", async () => { const onUploadEnd = jest.fn(); render( { files: [new File(["12345678"], "first.ttl"), new File(["12345678"], "second.ttl")], }, }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(2)); + await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(1)); act(() => ControlledXMLHttpRequest.requests[0].progress(4, 8)); expect(screen.getByRole("progressbar", { name: "Overall upload progress" })).toHaveAttribute( @@ -235,6 +235,7 @@ describe("FileUpload transport", () => { expect(screen.getByText("0 of 2 files completed")).toBeInTheDocument(); await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "first"); + await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(2)); await waitFor(() => expect(screen.getByText("1 of 2 files completed")).toBeInTheDocument()); expect(screen.getByRole("progressbar", { name: "Upload progress for first.ttl" })).toHaveAttribute( "aria-valuenow", @@ -243,6 +244,9 @@ describe("FileUpload transport", () => { expect( screen.getByRole("progressbar", { name: "Upload progress for first.ttl" }).closest("[role=listitem]"), ).toHaveAttribute("data-state", "complete"); + expect( + screen.getByRole("progressbar", { name: "Upload progress for first.ttl" }).firstElementChild, + ).toHaveClass("eccgui-progressbar-intent-success", "bp6-no-animation", "bp6-no-stripes"); expect(onUploadEnd).not.toHaveBeenCalled(); await completeRequest(ControlledXMLHttpRequest.requests[1], 200, "second"); await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); @@ -259,6 +263,7 @@ describe("FileUpload transport", () => { endpoint="/files" labels={labels} maxNumberOfFiles={2} + concurrency={2} onUploadSuccess={onUploadSuccess} onUploadError={onUploadError} onUploadEnd={onUploadEnd} @@ -284,6 +289,9 @@ describe("FileUpload transport", () => { expect( screen.getByRole("progressbar", { name: "Upload progress for second.ttl" }).closest("[role=listitem]"), ).toHaveAttribute("data-state", "error"); + expect( + screen.getByRole("progressbar", { name: "Upload progress for second.ttl" }).firstElementChild, + ).toHaveClass("eccgui-progressbar-intent-danger", "bp6-no-animation", "bp6-no-stripes"); expect(onUploadEnd).toHaveBeenCalledTimes(1); }); diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index a30e8435..32eef6dd 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -29,11 +29,13 @@ interface UploadFileState { } class ResponseParseError extends Error { + readonly cause: Error; readonly status: number; constructor(error: Error, status: number) { - super(error.message, { cause: error }); + super(error.message); this.name = "ResponseParseError"; + this.cause = error; this.status = status; } } @@ -133,6 +135,7 @@ function FileUploadInner( acceptedFileTypes, maxFileSize, maxNumberOfFiles = 1, + concurrency = 1, autoUpload = true, method = "POST", headers, @@ -212,6 +215,7 @@ function FileUploadInner( const currentHeaders = propsRef.current.headers; return typeof currentHeaders === "function" ? currentHeaders() : (currentHeaders ?? {}); }, + limit: concurrency, method, }), ); @@ -454,7 +458,19 @@ function FileUploadInner( aria-valuenow={uploadFile.progress} role="progressbar" > -
))} diff --git a/src/components/FileUpload/types.ts b/src/components/FileUpload/types.ts index 5ef74efc..37d29da3 100644 --- a/src/components/FileUpload/types.ts +++ b/src/components/FileUpload/types.ts @@ -63,6 +63,8 @@ interface FileUploadBaseProps { acceptedFileTypes?: string[]; maxFileSize?: number; maxNumberOfFiles?: number; + /** Maximum number of files uploaded at the same time. Defaults to 1. */ + concurrency?: number; autoUpload?: boolean; method?: "POST" | "PUT"; headers?: FileUploadHeaders; diff --git a/src/components/FileUpload/uppyHeadless.ts b/src/components/FileUpload/uppyHeadless.ts index ddd67d95..5366a2de 100644 --- a/src/components/FileUpload/uppyHeadless.ts +++ b/src/components/FileUpload/uppyHeadless.ts @@ -48,6 +48,7 @@ interface HeadlessXhrUploadOptions { endpoint: (file: HeadlessUppyFile) => string; getResponseData: (xhr: XMLHttpRequest) => unknown; headers: (file: HeadlessUppyFile) => Record; + limit: number; method: "POST" | "PUT"; } From 6556b0a9f24b55e4551a8ef45bd121f25624d901 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Thu, 17 Sep 2026 16:37:14 +0200 Subject: [PATCH 07/12] Refactor FileUpload API and centralize upload management - Use one scheduler across selections, asynchronous approvals and retries - Replace defer/restore and lifecycle callbacks with state snapshots and typed completion results - Retain cancelled files in progress totals until explicitly removed - Preserve completed history while releasing selection capacity - Separate selection disabling from upload disabling and unify error formatting - Add Remove option for cancelled uploads - Expand regression tests, API type checks, package checks and Storybook examples --- CHANGELOG.md | 14 +- package.json | 3 +- scripts/test-package-exports-commonjs.cjs | 10 + scripts/test-package-exports-esm.mjs | 10 + src/components/Button/Button.test.tsx | 9 + src/components/Button/Button.tsx | 43 +- .../FileUpload/FileUpload.stories.tsx | 66 +- src/components/FileUpload/FileUpload.test.tsx | 55 +- .../FileUpload/FileUpload.transport.test.tsx | 842 +++++++++++------- src/components/FileUpload/FileUpload.tsx | 569 +++++------- src/components/FileUpload/fileupload.scss | 14 + src/components/FileUpload/types.ts | 82 +- src/components/FileUpload/uppyHeadless.ts | 118 +-- 13 files changed, 949 insertions(+), 886 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d77c2d29..525c683d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,24 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Added +- `); expect(screen.getByRole("button")).toHaveTextContent(/default button/i); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 471e6cb7..ef789b06 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -73,21 +73,31 @@ export type ButtonProps = ExtendedButtonProps & ExtendedAnchorButtonProps; * Display a button element to enable user interaction. * It normally should trigger action when clicked. */ -export const Button = ({ - children, - className = "", - affirmative = false, - disruptive = false, - elevated = false, - icon, - rightIcon, - tooltip = null, - tooltipProps, - badge, - badgeProps = { size: "small", position: "top-right", maxLength: 2 }, - intent, - ...restProps -}: ButtonProps) => { +export const Button = React.forwardRef(function Button( + { + children, + className = "", + affirmative = false, + disruptive = false, + elevated = false, + icon, + rightIcon, + tooltip = null, + tooltipProps, + badge, + badgeProps = { size: "small", position: "top-right", maxLength: 2 }, + intent, + ...restProps + }, + ref, +) { + const setElementRef = React.useCallback( + (element: HTMLButtonElement | HTMLAnchorElement | null) => { + if (typeof ref === "function") ref(element); + else if (ref) ref.current = element; + }, + [ref], + ); let intentByFunction; switch (true) { case affirmative || elevated: @@ -110,6 +120,7 @@ export const Button = ({ const button = ( : icon} @@ -137,7 +148,7 @@ export const Button = ({ ) : ( button ); -}; +}); interface constructBadgePropertiesProps extends Pick, Pick {} diff --git a/src/components/FileUpload/FileUpload.stories.tsx b/src/components/FileUpload/FileUpload.stories.tsx index 0b2b9f2f..0f1dba9f 100644 --- a/src/components/FileUpload/FileUpload.stories.tsx +++ b/src/components/FileUpload/FileUpload.stories.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Meta, StoryFn } from "@storybook/react"; -import { waitFor, within } from "storybook/test"; +import { userEvent, waitFor, within } from "storybook/test"; import { FileUpload, FileUploadFile, FileUploadProps } from "../../index"; @@ -10,17 +10,23 @@ const defaultArgs: FileUploadProps = { acceptedFileTypes: [".ttl", ".nt", ".rdf"], maxFileSize: 10_000_000, labels: { + cancelFile: "Cancel upload", + continueUpload: "Continue uploads", dropHereOr: "Drop a graph file here or", browse: "browse files", + removeFile: "Remove", + removedFile: (file) => `${file.name} removed`, + formatError: ({ kind, error }) => + kind === "response" ? `Invalid response: ${error.message}` : `Upload failed: ${error.message}`, uploadProgress: "Files", overallUploadProgress: "Overall upload progress", + retry: "Retry", completedFiles: (completed, total) => `${completed} of ${total} files completed`, fileUploadProgress: (file) => `Upload progress for ${file.name}`, selectedFile: (file) => `Selected ${file.name}`, uploadedFile: (file) => `${file.name} uploaded`, - restrictionError: (_error, file) => `${file?.name ?? "File"} cannot be uploaded`, - responseError: (error, file) => `${file?.name ?? "File"} returned an invalid response: ${error.message}`, - transportError: (error, file) => `${file?.name ?? "File"} could not be uploaded: ${error.message}`, + stopUploads: "Stop uploads", + uploadCancelled: "Upload cancelled", }, instructions: "Turtle, N-Triples or RDF/XML; maximum 10 MB. Press Enter or Space to browse.", }; @@ -32,13 +38,13 @@ export default { parameters: { a11y: { test: "error" }, }, -} as Meta; +} as Meta; -const Template: StoryFn = (args) => ; +const Template: StoryFn = (args) => ; export const Idle = Template.bind({}); -const DraggingTemplate: StoryFn = (args) => { +const DraggingTemplate: StoryFn = (args) => { const storyRef = React.useRef(null); React.useEffect(() => { @@ -134,8 +140,6 @@ interface UploadStateStoryProps { const UploadStateStory = ({ args, files, stateForFile }: UploadStateStoryProps) => { const [transportReady, setTransportReady] = React.useState(false); - const storyRef = React.useRef(null); - const selected = React.useRef(false); React.useEffect(() => { const nativeXMLHttpRequest = window.XMLHttpRequest; @@ -146,20 +150,12 @@ const UploadStateStory = ({ args, files, stateForFile }: UploadStateStoryProps) }; }, [stateForFile]); - React.useEffect(() => { - if (!transportReady || selected.current) return; - const input = storyRef.current?.querySelector("input[type=file]"); - if (!(input instanceof HTMLInputElement)) return; - selected.current = true; - Object.defineProperty(input, "files", { configurable: true, value: files }); - input.dispatchEvent(new Event("change", { bubbles: true })); - }, [files, transportReady]); - return ( -
+
{transportReady && ( `/storybook-upload/${encodeURIComponent(file.name)}`} maxNumberOfFiles={Math.max(args.maxNumberOfFiles ?? 1, files.length)} /> @@ -188,12 +184,12 @@ const waitForAlert = async (canvasElement: HTMLElement) => { await waitFor(() => within(canvasElement).getByRole("alert"), { timeout: 3_000 }); }; -export const Uploading: StoryFn = (args) => ( +export const Uploading: StoryFn = (args) => ( ); Uploading.play = ({ canvasElement }) => waitForProgress(canvasElement, "Upload progress for graph.ttl", "45"); -export const Completed: StoryFn = (args) => ( +export const Completed: StoryFn = (args) => ( ); Completed.play = ({ canvasElement }) => waitForProgress(canvasElement, "Upload progress for graph.ttl", "100"); @@ -205,7 +201,7 @@ const multipleUploadingState = (fileName: string): StoryUploadState => { return { outcome: "uploading", progress: 20 }; }; -export const MultipleFilesUploading: StoryFn = (args) => ( +export const MultipleFilesUploading: StoryFn = (args) => ( ); MultipleFilesUploading.args = { concurrency: 3 }; @@ -218,7 +214,7 @@ const mixedResultState = (fileName: string): StoryUploadState => { return { outcome: "error", progress: 30 }; }; -export const MixedResults: StoryFn = (args) => ( +export const MixedResults: StoryFn = (args) => ( ); MixedResults.args = { concurrency: 3 }; @@ -236,12 +232,32 @@ MixedResults.play = async ({ canvasElement }) => { ); }; -export const TransportError: StoryFn = (args) => ( +export const TransportError: StoryFn = (args) => ( ); TransportError.play = ({ canvasElement }) => waitForAlert(canvasElement); -export const RestrictionError: StoryFn = (args) => ( +export const RestrictionError: StoryFn = (args) => ( ); RestrictionError.play = ({ canvasElement }) => waitForAlert(canvasElement); + +const equalFiles = ["first.ttl", "second.ttl", "third.ttl"].map((name) => new File(["data"], name)); + +export const CancelledFiles: StoryFn = (args) => ( + +); +CancelledFiles.play = async ({ canvasElement }) => { + await waitForProgress(canvasElement, "Upload progress for second.ttl", "65"); + await userEvent.click(within(canvasElement).getByRole("button", { name: "Stop uploads" })); + await waitForProgress(canvasElement, "Overall upload progress", "33"); +}; + +export const RemovedBeforeContinue = CancelledFiles.bind({}); +RemovedBeforeContinue.play = async (context) => { + await CancelledFiles.play!(context); + await userEvent.click( + within(context.canvasElement).getByRole("button", { name: "Remove", description: "third.ttl" }), + ); + await waitForProgress(context.canvasElement, "Overall upload progress", "50"); +}; diff --git a/src/components/FileUpload/FileUpload.test.tsx b/src/components/FileUpload/FileUpload.test.tsx index f5dc0e62..384d5840 100644 --- a/src/components/FileUpload/FileUpload.test.tsx +++ b/src/components/FileUpload/FileUpload.test.tsx @@ -9,10 +9,21 @@ import { SimpleDialog } from "../Dialog"; import FileUpload from "./FileUpload"; const labels = { + cancelFile: "Cancel upload", + continueUpload: "Continue uploads", dropHereOr: "Drop a project file here or", browse: "browse files", + uploadedFile: (file: { name: string }) => `${file.name} uploaded`, + selectedFile: (file: { name: string }) => `${file.name} selected`, + removeFile: "Remove", + removedFile: (file: { name: string }) => `${file.name} removed`, + formatError: ({ kind, error }: { kind: string; error: Error }) => + kind === "response" ? `Invalid response: ${error.message}` : `Upload failed: ${error.message}`, uploadProgress: "Upload progress", overallUploadProgress: "Overall upload progress", + retry: "Retry", + stopUploads: "Stop uploads", + uploadCancelled: "Upload cancelled", completedFiles: (completed: number, total: number) => `${completed} of ${total} files completed`, fileUploadProgress: (file: { name: string }) => `Upload progress for ${file.name}`, }; @@ -20,6 +31,7 @@ const labels = { const renderFileUpload = (props: Partial> = {}) => render( { it("creates stable, unique relationships for multiple uploaders", () => { const { rerender } = render( <> - - + + , ); @@ -68,8 +80,8 @@ describe("FileUpload", () => { rerender( <> - - + + , ); @@ -86,6 +98,23 @@ describe("FileUpload", () => { fireEvent.change(input, { target: { files: [new File(["data"], "vocabulary.ttl", { type: "text/turtle" })] } }); expect(screen.getByRole("status")).toHaveTextContent("vocabulary.ttl"); + expect(screen.getByRole("status")).toHaveClass("cds--visually-hidden"); + }); + + it("reports an unrestricted multi-file selection as one batch", () => { + const onFilesAdded = jest.fn(); + renderFileUpload({ autoUpload: false, maxNumberOfFiles: null, onFilesAdded }); + const input = document.querySelector("input[type=file]") as HTMLInputElement; + const files = [new File(["one"], "one.ttl"), new File(["two"], "two.ttl")]; + + fireEvent.change(input, { target: { files } }); + + expect(input).toHaveAttribute("multiple"); + expect(onFilesAdded).toHaveBeenCalledTimes(1); + expect(onFilesAdded).toHaveBeenCalledWith([ + expect.objectContaining({ name: "one.ttl" }), + expect.objectContaining({ name: "two.ttl" }), + ]); }); it("opens the native picker exactly once per button activation", () => { @@ -107,7 +136,13 @@ describe("FileUpload", () => { rerender( - + , ); @@ -122,6 +157,7 @@ describe("FileUpload", () => { return ( { ...props, labels: { ...labels, - restrictionError: (_error, file) => `File rejected: ${file?.name ?? "selection"}`, + formatError: ({ file }) => `File rejected: ${file?.name ?? "selection"}`, }, }); const input = document.querySelector("input[type=file]") as HTMLInputElement; @@ -188,7 +224,7 @@ describe("FileUpload", () => { expect(screen.getByRole("button", { name: /browse files/i })).toHaveAccessibleDescription( screen.getByRole("alert").textContent!, ); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); }); it("rejects a selection that exceeds the maximum file count", () => { @@ -200,7 +236,7 @@ describe("FileUpload", () => { }); expect(screen.getByRole("alert")).not.toBeEmptyDOMElement(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); }); it("uses the localized name as an aria-label when its visible label is hidden", () => { @@ -219,6 +255,7 @@ describe("FileUpload", () => { rerender( { dataTransfer: { files: [new File(["data"], "dropped.ttl")] }, }); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); }); }); diff --git a/src/components/FileUpload/FileUpload.transport.test.tsx b/src/components/FileUpload/FileUpload.transport.test.tsx index 38714701..a066d09b 100644 --- a/src/components/FileUpload/FileUpload.transport.test.tsx +++ b/src/components/FileUpload/FileUpload.transport.test.tsx @@ -1,457 +1,613 @@ import React from "react"; -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import "@testing-library/jest-dom"; import FileUpload from "./FileUpload"; -import { FileUploadHandle } from "./types"; - -const labels = { - browse: "browse files", - completedFiles: (completed: number, total: number) => `${completed} of ${total} files completed`, - dropHereOr: "Drop files here or", - fileUploadProgress: (file: { name: string }) => `Upload progress for ${file.name}`, - overallUploadProgress: "Overall upload progress", - responseError: (error: Error) => `Invalid response: ${error.message}`, - transportError: (error: Error) => `Upload failed: ${error.message}`, - uploadProgress: "Upload progress", - uploadedFile: (file: { name: string }) => `${file.name} uploaded`, -}; - -class ControlledXMLHttpRequest { - static requests: ControlledXMLHttpRequest[] = []; - - method = ""; - url = ""; - requestBody: Document | XMLHttpRequestBodyInit | null = null; - requestHeaders: Record = {}; - response: unknown; - responseText = ""; - responseType: XMLHttpRequestResponseType = ""; - status = 0; - statusText = ""; - withCredentials = false; - aborted = false; - sent = false; - onerror: (() => void | Promise) | null = null; - onload: (() => void | Promise) | null = null; - upload = { onprogress: null as ((event: ProgressEvent) => void) | null }; - - constructor() { - ControlledXMLHttpRequest.requests.push(this); - } - - abort() { - this.aborted = true; - } - - open(method: string, url: string) { - this.method = method; - this.url = url; - } - - send(body: Document | XMLHttpRequestBodyInit | null) { - this.requestBody = body; - this.sent = true; - } - - setRequestHeader(name: string, value: string) { - this.requestHeaders[name] = value; - } - - progress(loaded: number, total: number) { - this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent); - } - - async fail(message: string) { - this.statusText = message; - await this.onerror?.(); - } - - async respond(status: number, responseText: string) { - this.status = status; - this.responseText = responseText; - await this.onload?.(); - } -} - -class ControlledFormData { - append() {} -} - -const NativeXMLHttpRequest = global.XMLHttpRequest; -const NativeFormData = global.FormData; -const nativeAbortSignalAny = AbortSignal.any; - -const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => { - const controller = new AbortController(); - signals.forEach((signal) => { - if (signal.aborted) controller.abort(); - else signal.addEventListener("abort", () => controller.abort(), { once: true }); - }); - return controller.signal; -}; - -const completeRequest = async (request: ControlledXMLHttpRequest, status: number, responseText: string) => { - await act(async () => { - await request.respond(status, responseText); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); +import { completeRequest, ControlledXMLHttpRequest, failRequest, labels } from "./testHelpers"; +import { FileUploadHandle, FileUploadResult } from "./types"; + +const select = (...files: File[]) => + fireEvent.change(document.querySelector("input[type=file]")!, { target: { files } }); +const file = (name: string, contents = "data") => new File([contents], name); +const request = async (index: number) => { + await waitFor(() => expect(ControlledXMLHttpRequest.requests[index]?.sent).toBe(true)); + return ControlledXMLHttpRequest.requests[index]; }; - -const failRequest = async (request: ControlledXMLHttpRequest, message: string) => { - await act(async () => { - await request.fail(message); - await new Promise((resolve) => setTimeout(resolve, 0)); +const row = (name: string) => + screen.getByRole("progressbar", { name: `Upload progress for ${name}` }).closest('[role="listitem"]')!; +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; }); + return { promise, resolve, reject }; }; -beforeEach(() => { - ControlledXMLHttpRequest.requests = []; - global.XMLHttpRequest = ControlledXMLHttpRequest as unknown as typeof XMLHttpRequest; - window.XMLHttpRequest = ControlledXMLHttpRequest as unknown as typeof XMLHttpRequest; - global.FormData = ControlledFormData as unknown as typeof FormData; - window.FormData = ControlledFormData as unknown as typeof FormData; - AbortSignal.any = combineAbortSignals; -}); - -afterEach(() => { - jest.restoreAllMocks(); - global.XMLHttpRequest = NativeXMLHttpRequest; - window.XMLHttpRequest = NativeXMLHttpRequest; - global.FormData = NativeFormData; - window.FormData = NativeFormData; - AbortSignal.any = nativeAbortSignalAny; -}); - describe("FileUpload transport", () => { - it("uses current request configuration and emits the documented successful lifecycle", async () => { - const events: string[] = []; - const endpoint = jest.fn((file) => `/files/${file.name}`); - const headers = jest.fn(() => ({ Authorization: "current token" })); - const onUploadProgress = jest.fn((value) => events.push(`progress:${value}`)); - const onUploadSuccess = jest.fn((response) => events.push(`success:${response.body}`)); + it("uses short Remove labels and focuses the next or previous Remove after committing removal", async () => { + render(); + select(file("first.ttl"), file("second.ttl"), file("third.ttl")); + await request(0); + fireEvent.click(screen.getByRole("button", { name: "Stop uploads" })); + const remove = (name: string) => screen.getByRole("button", { name: "Remove", description: name }); + expect(remove("first.ttl")).toHaveTextContent(/^Remove$/); + fireEvent.click(remove("second.ttl")); + expect(remove("third.ttl")).toHaveFocus(); + expect(screen.queryByText("second.ttl")).not.toBeInTheDocument(); + fireEvent.click(remove("third.ttl")); + expect(remove("first.ttl")).toHaveFocus(); + fireEvent.click(remove("first.ttl")); + expect(screen.getByRole("button", { name: /browse files/ })).toHaveFocus(); + }); + it("preserves both successes when another selection joins an active upload", async () => { + const onUploadSuccess = jest.fn(); render( responseText.toUpperCase()} - onUploadStart={() => events.push("start")} - onUploadProgress={onUploadProgress} + maxNumberOfFiles={null} onUploadSuccess={onUploadSuccess} - onUploadEnd={() => events.push("end")} />, ); + const input = document.querySelector("input[type=file]")!; + fireEvent.change(input, { target: { files: [new File(["one"], "one.ttl")] } }); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); + fireEvent.change(input, { target: { files: [new File(["two"], "two.ttl")] } }); + await waitFor(() => + expect(screen.getByRole("progressbar", { name: "Upload progress for two.ttl" })).toBeInTheDocument(), + ); + await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "one"); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[1]?.sent).toBe(true)); + await completeRequest(ControlledXMLHttpRequest.requests[1], 200, "two"); + await waitFor(() => expect(onUploadSuccess).toHaveBeenCalledTimes(2)); + expect(screen.getByText("2 of 2 files completed")).toBeInTheDocument(); + }); + it("counts cancelled files until removal before continuing the remaining uploads", async () => { + render( + , + ); fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["contents"], "vocabulary.ttl")] }, + target: { + files: [new File(["aaa"], "a.ttl"), new File(["bbb"], "b.ttl"), new File(["ccc"], "c.ttl")], + }, }); await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); - const request = ControlledXMLHttpRequest.requests[0]; - - expect(request.method.toUpperCase()).toBe("PUT"); - expect(request.url).toBe("/files/vocabulary.ttl"); - expect(request.requestHeaders).toEqual({ Authorization: "current token" }); - expect(endpoint).toHaveBeenCalledWith(expect.objectContaining({ name: "vocabulary.ttl" })); - act(() => request.progress(4, 8)); - expect(screen.getByRole("progressbar", { name: "Upload progress for vocabulary.ttl" })).toHaveAttribute( + await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "a"); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[1]?.sent).toBe(true)); + fireEvent.click(screen.getByRole("button", { name: "Stop uploads" })); + expect(screen.getByRole("progressbar", { name: "Overall upload progress" })).toHaveAttribute( "aria-valuenow", - "50", + "33", ); - expect(screen.queryByRole("progressbar", { name: "Overall upload progress" })).not.toBeInTheDocument(); - await completeRequest(request, 201, "upload-id"); - - await waitFor(() => expect(onUploadSuccess).toHaveBeenCalled()); - expect(onUploadSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - body: "UPLOAD-ID", - status: 201, - file: expect.objectContaining({ name: "vocabulary.ttl" }), - }), + fireEvent.click(screen.getByRole("button", { name: "Remove", description: "b.ttl" })); + expect(screen.getByRole("progressbar", { name: "Overall upload progress" })).toHaveAttribute( + "aria-valuenow", + "50", ); - await waitFor(() => expect(events.at(-1)).toBe("end")); - expect(events).toEqual(["start", "progress:50", "success:UPLOAD-ID", "progress:100", "end"]); - expect(screen.getByRole("status")).toHaveTextContent("vocabulary.ttl uploaded"); + fireEvent.click(screen.getByRole("button", { name: "Continue uploads" })); + await waitFor(() => expect(ControlledXMLHttpRequest.requests[2]?.sent).toBe(true)); + await completeRequest(ControlledXMLHttpRequest.requests[2], 200, "c"); + expect(screen.getByText("2 of 2 files completed")).toBeInTheDocument(); + expect(ControlledXMLHttpRequest.requests.filter((request) => request.sent)).toHaveLength(3); }); - it("waits for the imperative upload call in manual mode and deduplicates concurrent calls", async () => { - const uploadRef = React.createRef(); + it("preserves a nullable parser result instead of replacing it with response text", async () => { + const onUploadSuccess = jest.fn(); render( - , + null} + onUploadSuccess={onUploadSuccess} + />, ); fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["contents"], "manual.ttl")] }, + target: { files: [new File(["a"], "a.ttl")] }, }); - expect(ControlledXMLHttpRequest.requests).toHaveLength(0); - - let firstUpload!: Promise; - let secondUpload!: Promise; - act(() => { - firstUpload = uploadRef.current!.upload(); - secondUpload = uploadRef.current!.upload(); - }); - expect(secondUpload).toBe(firstUpload); await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); - await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "done"); - await firstUpload; + await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "ignored"); + expect(onUploadSuccess).toHaveBeenCalledWith(expect.objectContaining({ body: null })); }); - it("uploads sequentially by default and shows aggregate and per-file progress", async () => { - const onUploadEnd = jest.fn(); + it("returns one typed result for overlapping manual calls and publishes state before completion", async () => { + const ref = React.createRef>(); + const state = jest.fn(); + const onComplete = jest.fn(() => + expect(state).toHaveBeenLastCalledWith(expect.objectContaining({ allSuccessful: true })), + ); render( ({ id: responseText })} + onStateChange={state} + onComplete={onComplete} />, ); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { - files: [new File(["12345678"], "first.ttl"), new File(["12345678"], "second.ttl")], - }, + select(file("manual.ttl")); + expect(ControlledXMLHttpRequest.requests).toHaveLength(0); + let first!: Promise>; + act(() => { + first = ref.current!.upload(); + expect(ref.current!.upload()).toBe(first); }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(1)); - - act(() => ControlledXMLHttpRequest.requests[0].progress(4, 8)); - expect(screen.getByRole("progressbar", { name: "Overall upload progress" })).toHaveAttribute( - "aria-valuenow", - "25", + await completeRequest(await request(0), 201, "upload-id"); + expect(await first).toEqual( + expect.objectContaining({ + reason: "settled", + successful: [expect.objectContaining({ body: { id: "upload-id" }, status: 201 })], + }), ); - expect(screen.getByRole("progressbar", { name: "Upload progress for first.ttl" })).toHaveAttribute( - "aria-valuenow", - "50", + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it("lets approved files upload while another approval waits and completes only after all decisions", async () => { + const approval = deferred(); + const onComplete = jest.fn(); + render( + "/files/" + f.name} + labels={labels} + maxNumberOfFiles={null} + beforeUpload={(f) => (f.name === "existing.ttl" ? approval.promise : Promise.resolve(true))} + onComplete={onComplete} + />, ); - expect(screen.getByRole("progressbar", { name: "Upload progress for second.ttl" })).toHaveAttribute( - "aria-valuenow", - "0", + select(file("existing.ttl"), file("new.ttl")); + const first = await request(0); + expect(first.url).toBe("/files/new.ttl"); + await completeRequest(first, 200, "new"); + expect(onComplete).not.toHaveBeenCalled(); + await act(async () => approval.resolve(true)); + const second = await request(1); + expect(second.url).toBe("/files/existing.ttl"); + expect(screen.getByText("1 of 2 files completed")).toBeInTheDocument(); + await completeRequest(second, 200, "existing"); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenLastCalledWith( + expect.objectContaining({ + successful: expect.arrayContaining([ + expect.objectContaining({ file: expect.objectContaining({ name: "new.ttl" }) }), + expect.objectContaining({ file: expect.objectContaining({ name: "existing.ttl" }) }), + ]), + }), ); - expect(screen.getAllByRole("progressbar")).toHaveLength(3); - expect(screen.getByText("0 of 2 files completed")).toBeInTheDocument(); + }); - await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "first"); - await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(2)); - await waitFor(() => expect(screen.getByText("1 of 2 files completed")).toBeInTheDocument()); - expect(screen.getByRole("progressbar", { name: "Upload progress for first.ttl" })).toHaveAttribute( - "aria-valuenow", - "100", + it("declines without a request and retries a rejected approval", async () => { + const beforeUpload = jest + .fn() + .mockRejectedValueOnce(new Error("Check unavailable")) + .mockResolvedValueOnce(true); + const onUploadError = jest.fn(); + const { rerender } = render( + , ); - expect( - screen.getByRole("progressbar", { name: "Upload progress for first.ttl" }).closest("[role=listitem]"), - ).toHaveAttribute("data-state", "complete"); - expect( - screen.getByRole("progressbar", { name: "Upload progress for first.ttl" }).firstElementChild, - ).toHaveClass("eccgui-progressbar-intent-success", "bp6-no-animation", "bp6-no-stripes"); - expect(onUploadEnd).not.toHaveBeenCalled(); - await completeRequest(ControlledXMLHttpRequest.requests[1], 200, "second"); - await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); + select(file("checked.ttl")); + await screen.findByRole("alert"); + expect(onUploadError).toHaveBeenCalledWith(expect.objectContaining({ kind: "validation" })); + expect(ControlledXMLHttpRequest.requests).toHaveLength(0); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await completeRequest(await request(0), 200, "done"); + expect(beforeUpload).toHaveBeenCalledTimes(2); + rerender( false} />); + select(file("declined.ttl")); + await waitFor(() => expect(screen.queryByText("declined.ttl")).not.toBeInTheDocument()); + expect(ControlledXMLHttpRequest.requests).toHaveLength(1); }); - it("reports mixed batch results per file and ends the batch once", async () => { - jest.spyOn(console, "error").mockImplementation(() => undefined); + it.each(["cancel", "reset", "remove", "unmount"] as const)("ignores pending approval after %s", async (action) => { + const approval = deferred(); + const beforeUpload = jest.fn(() => approval.promise); + const ref = React.createRef(); + const onFilesAdded = jest.fn(); const onUploadSuccess = jest.fn(); - const onUploadError = jest.fn(); - const onUploadEnd = jest.fn(); - render( + const { unmount } = render( , ); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["first"], "first.ttl"), new File(["second"], "second.ttl")] }, + select(file("pending.ttl")); + await waitFor(() => expect(beforeUpload).toHaveBeenCalled()); + act(() => { + if (action === "unmount") unmount(); + else if (action === "remove") ref.current!.remove(onFilesAdded.mock.calls[0][0][0].id); + else ref.current![action](); }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests).toHaveLength(2)); + await act(async () => approval.resolve(true)); + expect(beforeUpload.mock.calls[0][1].aborted).toBe(true); + expect(ControlledXMLHttpRequest.requests).toHaveLength(0); + expect(onUploadSuccess).not.toHaveBeenCalled(); + }); - await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "first-id"); - expect(onUploadSuccess).toHaveBeenCalledTimes(1); - expect(onUploadEnd).not.toHaveBeenCalled(); - for (let attempt = 1; attempt <= 4; attempt += 1) { - await waitFor(() => expect(ControlledXMLHttpRequest.requests[attempt]?.sent).toBe(true)); - await failRequest(ControlledXMLHttpRequest.requests[attempt], "Connection lost"); - } + it("retains completed history and announcements while accepting the next single file", async () => { + render(); + select(file("first.ttl")); + await completeRequest(await request(0), 200, "first"); + expect(screen.getByRole("status")).toHaveTextContent("first.ttl uploaded"); + select(file("second.ttl")); + await completeRequest(await request(1), 200, "second"); + expect(screen.getByText("2 of 2 files completed")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("second.ttl uploaded"); + }); - await waitFor(() => expect(onUploadError).toHaveBeenCalledTimes(1)); - expect(onUploadError).toHaveBeenCalledWith( - expect.objectContaining({ kind: "transport", file: expect.objectContaining({ name: "second.ttl" }) }), + it("includes pending and cancelled files in the selection limit", async () => { + const approval = deferred(); + render( approval.promise} />); + select(file("first.ttl")); + select(file("second.ttl")); + expect(screen.getByRole("alert")).toHaveTextContent(/only upload 1/i); + fireEvent.click(screen.getByRole("button", { name: "Cancel upload" })); + select(file("third.ttl")); + expect(screen.queryByText("third.ttl")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Remove", description: "first.ttl" })); + select(file("fourth.ttl")); + expect(screen.getByText("fourth.ttl")).toBeInTheDocument(); + }); + + it("uses updated endpoints, headers, method and callbacks for subsequent requests", async () => { + const oldSuccess = jest.fn(), + success = jest.fn(); + const { rerender } = render( + , + ); + select(file("first.ttl"), file("second.ttl")); + const first = await request(0); + rerender( + "/new/" + f.name} + method="PUT" + labels={labels} + headers={() => ({ Authorization: "current token" })} + maxNumberOfFiles={null} + onUploadSuccess={success} + />, ); - expect( - screen.getByRole("progressbar", { name: "Upload progress for second.ttl" }).closest("[role=listitem]"), - ).toHaveAttribute("data-state", "error"); - expect( - screen.getByRole("progressbar", { name: "Upload progress for second.ttl" }).firstElementChild, - ).toHaveClass("eccgui-progressbar-intent-danger", "bp6-no-animation", "bp6-no-stripes"); - expect(onUploadEnd).toHaveBeenCalledTimes(1); + await completeRequest(first, 200, "first"); + const second = await request(1); + expect(second.url).toBe("/new/second.ttl"); + expect(second.method.toUpperCase()).toBe("PUT"); + expect(second.requestHeaders).toEqual({ Authorization: "current token" }); + await completeRequest(second, 200, "second"); + expect(oldSuccess).not.toHaveBeenCalled(); + expect(success).toHaveBeenCalledTimes(2); }); - it("classifies parser failures as response errors with the HTTP status", async () => { - jest.spyOn(console, "error").mockImplementation(() => undefined); - const onUploadError = jest.fn(); - const onUploadEnd = jest.fn(); + it("weights progress by bytes and shares a changing concurrency limit across requests", async () => { + const { rerender } = render( + , + ); + select(file("small.ttl", "aa"), file("large.ttl", "bbbbbbbb"), file("last.ttl", "cc")); + const first = await request(0); + act(() => first.progress(1, 2)); + expect(screen.getByRole("progressbar", { name: "Overall upload progress" })).toHaveAttribute( + "aria-valuenow", + "8", + ); + expect(ControlledXMLHttpRequest.requests).toHaveLength(1); + rerender( + , + ); + const second = await request(1); + expect(ControlledXMLHttpRequest.requests).toHaveLength(2); + await completeRequest(first, 200, "small"); + const third = await request(2); + await completeRequest(second, 200, "large"); + await completeRequest(third, 200, "last"); + expect(screen.getByText("3 of 3 files completed")).toBeInTheDocument(); + }); + + it("queues a cancelled-file retry behind the active file without another approval", async () => { + const beforeUpload = jest.fn(async () => true); + const onComplete = jest.fn(); render( - - name="Parsed upload" + "/files/" + f.name} + labels={labels} + maxNumberOfFiles={null} + beforeUpload={beforeUpload} + onComplete={onComplete} + />, + ); + select(file("first.ttl"), file("second.ttl")); + const first = await request(0); + fireEvent.click(within(row("first.ttl")).getByRole("button", { name: "Cancel upload" })); + const second = await request(1); + expect(first.aborted).toBe(true); + fireEvent.click(within(row("first.ttl")).getByRole("button", { name: "Retry" })); + expect(row("first.ttl")).toHaveAttribute("data-state", "queued"); + expect(ControlledXMLHttpRequest.requests).toHaveLength(2); + await completeRequest(second, 200, "second"); + const retry = await request(2); + expect(retry.url).toBe("/files/first.ttl"); + await completeRequest(retry, 200, "first"); + expect(beforeUpload).toHaveBeenCalledTimes(2); + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it("reports mixed results and permits a failed file to be retried", async () => { + const onComplete = jest.fn(), + onUploadError = jest.fn(); + render( + { - throw new Error("Expected a numeric identifier"); - }} + maxNumberOfFiles={null} + onComplete={onComplete} onUploadError={onUploadError} - onUploadEnd={onUploadEnd} />, ); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["contents"], "invalid-response.ttl")] }, - }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); - await completeRequest(ControlledXMLHttpRequest.requests[0], 200, "not-a-number"); - - await waitFor(() => - expect(onUploadError).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "response", - status: 200, - file: expect.objectContaining({ name: "invalid-response.ttl" }), - }), - ), + select(file("good.ttl"), file("bad.ttl")); + await completeRequest(await request(0), 200, "good"); + for (let attempt = 1; attempt <= 4; attempt++) await completeRequest(await request(attempt), 400, "bad"); + expect(onUploadError).toHaveBeenCalledWith(expect.objectContaining({ kind: "transport", status: 400 })); + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + successful: [expect.anything()], + failed: [expect.anything()], + }), ); - expect(screen.getByRole("alert")).toHaveTextContent("Invalid response: Expected a numeric identifier"); - await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await completeRequest(await request(5), 200, "recovered"); + expect(screen.getByText("2 of 2 files completed")).toBeInTheDocument(); + expect(onComplete).toHaveBeenCalledTimes(2); }); - it("classifies an exhausted request as a transport error and ends the batch once", async () => { - jest.spyOn(console, "error").mockImplementation(() => undefined); + it("classifies parsing errors without a fallback parser", async () => { const onUploadError = jest.fn(); - const onUploadEnd = jest.fn(); render( { + throw new Error("Bad body"); + }} onUploadError={onUploadError} - onUploadEnd={onUploadEnd} />, ); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["contents"], "network-error.ttl")] }, - }); + select(file("body.ttl")); + await completeRequest(await request(0), 201, "body"); + expect(onUploadError).toHaveBeenCalledWith(expect.objectContaining({ kind: "response", status: 201 })); + expect(screen.getByRole("alert")).toHaveTextContent("Invalid response: Bad body"); + }); + + it("reports an exhausted network error once", async () => { + const onUploadError = jest.fn(); + render(); + select(file("body.ttl")); + for (let attempt = 0; attempt < 4; attempt++) await failRequest(await request(attempt), "Network error"); + await waitFor(() => expect(onUploadError).toHaveBeenCalledTimes(1)); + }); - for (let attempt = 0; attempt < 4; attempt += 1) { - await waitFor(() => expect(ControlledXMLHttpRequest.requests[attempt]?.sent).toBe(true)); - await failRequest(ControlledXMLHttpRequest.requests[attempt], "Connection lost"); - } + it("separates selection disabling from retry and full disabling", async () => { + const ref = React.createRef(); + const { rerender } = render(); + select(file("retry.ttl")); + await request(0); + fireEvent.click(screen.getByRole("button", { name: "Cancel upload" })); + rerender(); + expect(screen.getByRole("button", { name: /browse files/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); + rerender(); + expect(screen.getByRole("button", { name: "Retry" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Remove", description: "retry.ttl" })).toBeEnabled(); + await expect(ref.current!.upload()).rejects.toThrow("disabled"); + }); - await waitFor(() => - expect(onUploadError).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "transport", - file: expect.objectContaining({ name: "network-error.ttl" }), - }), - ), + it("removes the final unsuccessful row without manufacturing another success or completion", async () => { + const state = jest.fn(), + success = jest.fn(), + complete = jest.fn(); + const ref = React.createRef(); + render( + , ); - expect(screen.getByRole("alert")).toHaveTextContent("Upload failed:"); - await waitFor(() => expect(onUploadEnd).toHaveBeenCalledTimes(1)); + select(file("good.ttl"), file("cancelled.ttl")); + await completeRequest(await request(0), 200, "good"); + await request(1); + fireEvent.click(screen.getByRole("button", { name: "Cancel upload" })); + await waitFor(() => expect(complete).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("button", { name: "Remove", description: "cancelled.ttl" })); + expect(screen.getByRole("status")).toHaveTextContent("cancelled.ttl removed"); + expect(screen.getByRole("button", { name: /browse files/ })).toHaveFocus(); + expect(state).toHaveBeenLastCalledWith(expect.objectContaining({ progress: 100, allSuccessful: true })); + expect(success).toHaveBeenCalledTimes(1); + expect(complete).toHaveBeenCalledTimes(1); + act(() => ref.current!.reset()); + expect(state).toHaveBeenLastCalledWith(expect.objectContaining({ progress: 0, allSuccessful: false })); }); - it("aborts an active request and ends the batch once without reporting an error", async () => { - const uploadRef = React.createRef(); - const onUploadError = jest.fn(); - const onUploadEnd = jest.fn(); + it.each([null, undefined])("preserves a parser returning %s", async (value) => { + const success = jest.fn(); render( value} + onUploadSuccess={success} />, ); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["contents"], "cancel.ttl")] }, - }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); - expect(screen.getByRole("group", { name: "Cancelable upload" })).toHaveAttribute("aria-busy", "true"); + select(file("result.ttl")); + await completeRequest(await request(0), 200, "not-json"); + expect(success).toHaveBeenCalledWith(expect.objectContaining({ body: value })); + }); - act(() => { - uploadRef.current!.cancel(); - uploadRef.current!.cancel(); - }); + it("initializes native files once in StrictMode and never re-adds them after reset", async () => { + const ref = React.createRef(); + const initial = file("initial.ttl"); + const element = () => ( + + + + ); + const { rerender } = render(element()); + await completeRequest(await request(0), 200, "initial"); + rerender(element()); + act(() => ref.current!.reset()); + rerender(element()); + await act(async () => {}); + expect(ControlledXMLHttpRequest.requests).toHaveLength(1); + expect(screen.queryByText("initial.ttl")).not.toBeInTheDocument(); + }); - expect(ControlledXMLHttpRequest.requests[0].aborted).toBe(true); - expect(onUploadError).not.toHaveBeenCalled(); - expect(onUploadEnd).toHaveBeenCalledTimes(1); - expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); - expect(screen.getByRole("group", { name: "Cancelable upload" })).not.toHaveAttribute("aria-busy"); + it("keeps focus in the widget after removing its last actionable row while disabled", async () => { + const props = { name: "Disabled removal", endpoint: "/files", labels }; + const { rerender } = render(); + select(file("cancelled.ttl")); + await request(0); + fireEvent.click(screen.getByRole("button", { name: "Cancel upload" })); + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Remove", description: "cancelled.ttl" })); + expect(screen.getByRole("group", { name: "Disabled removal" })).toHaveFocus(); }); - it("resets an active upload idempotently and accepts a fresh selection", async () => { - const uploadRef = React.createRef(); - const onUploadEnd = jest.fn(); + it("does not confuse transferred bytes with successful HTTP completion", async () => { + const state = jest.fn(), + success = jest.fn(), + complete = jest.fn(); render( , ); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["first"], "first.ttl")] }, - }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); - act(() => ControlledXMLHttpRequest.requests[0].progress(1, 5)); + select(file("waiting.ttl")); + const xhr = await request(0); + act(() => xhr.progress(4, 4)); + expect(state).toHaveBeenLastCalledWith( + expect.objectContaining({ progress: 100, allSuccessful: false, uploading: 1 }), + ); + expect(success).not.toHaveBeenCalled(); + expect(complete).not.toHaveBeenCalled(); + await completeRequest(xhr, 200, "done"); + expect(state).toHaveBeenLastCalledWith(expect.objectContaining({ allSuccessful: true, uploading: 0 })); + }); - act(() => { - uploadRef.current!.reset(); - uploadRef.current!.reset(); - }); + it("pauses queued requests when disabled without aborting active work", async () => { + const props = { name: "Paused", endpoint: "/files", labels, maxNumberOfFiles: null }; + const { rerender } = render(); + select(file("first.ttl"), file("second.ttl")); + const first = await request(0); + rerender(); + await completeRequest(first, 200, "done"); + expect(ControlledXMLHttpRequest.requests).toHaveLength(1); + expect(screen.getByRole("button", { name: "Cancel upload" })).toBeEnabled(); + rerender(); + await completeRequest(await request(1), 200, "done"); + expect(screen.getByText("2 of 2 files completed")).toBeInTheDocument(); + }); - expect(ControlledXMLHttpRequest.requests[0].aborted).toBe(true); - expect(onUploadEnd).toHaveBeenCalledTimes(1); - expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + it("applies initial-file restrictions before approval or transport", async () => { + const beforeUpload = jest.fn(async () => true); + render( + , + ); + await screen.findByRole("alert"); + expect(beforeUpload).not.toHaveBeenCalled(); + expect(ControlledXMLHttpRequest.requests).toHaveLength(0); + }); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["second"], "second.ttl")] }, - }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests[1]?.sent).toBe(true)); + it("uses completed file counts for zero-byte progress and retains cancelled files", async () => { + const state = jest.fn(); + render( + , + ); + select(file("first.ttl", ""), file("second.ttl", "")); + await completeRequest(await request(0), 200, "done"); + await request(1); + fireEvent.click(screen.getByRole("button", { name: "Stop uploads" })); + expect(state).toHaveBeenLastCalledWith(expect.objectContaining({ progress: 50, allSuccessful: false })); + fireEvent.click(screen.getByRole("button", { name: "Remove", description: "second.ttl" })); + expect(state).toHaveBeenLastCalledWith(expect.objectContaining({ progress: 100, allSuccessful: true })); }); - it("aborts on unmount without firing late consumer callbacks", async () => { - const onUploadEnd = jest.fn(); - const onUploadSuccess = jest.fn(); + it("cancels and settles a manual promise on unmount without late callbacks", async () => { + const ref = React.createRef(); + const onComplete = jest.fn(), + onUploadSuccess = jest.fn(); const { unmount } = render( , ); - fireEvent.change(document.querySelector("input[type=file]")!, { - target: { files: [new File(["contents"], "unmount.ttl")] }, + select(file("late.ttl")); + let promise!: Promise>; + act(() => { + promise = ref.current!.upload(); }); - await waitFor(() => expect(ControlledXMLHttpRequest.requests[0]?.sent).toBe(true)); - + const xhr = await request(0); unmount(); - - expect(ControlledXMLHttpRequest.requests[0].aborted).toBe(true); + expect(await promise).toEqual(expect.objectContaining({ reason: "cancelled" })); + expect(xhr.aborted).toBe(true); + await completeRequest(xhr, 200, "late"); + expect(onComplete).not.toHaveBeenCalled(); expect(onUploadSuccess).not.toHaveBeenCalled(); - expect(onUploadEnd).not.toHaveBeenCalled(); }); }); diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index 32eef6dd..9fd650a1 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -1,62 +1,29 @@ import React from "react"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; +import Button from "../Button/Button"; import Icon from "../Icon/Icon"; import ProgressBar from "../ProgressBar/ProgressBar"; import { - FileUploadError, - FileUploadFile, + FileUploadBaseProps, FileUploadHandle, - FileUploadProps, + FileUploadLabels, + FileUploadParsedProps, FileUploadResponseMetadata, + FileUploadTextProps, } from "./types"; -import { - HeadlessFileProgress, - HeadlessUppyFile, - HeadlessUploadResponse, - Uppy, - UppyContextProvider, - useDropzone, - useFileInput, - XHRUpload, -} from "./uppyHeadless"; - -interface UploadFileState { - file: FileUploadFile; - progress: number; - status: "uploading" | "complete" | "error"; -} - -class ResponseParseError extends Error { - readonly cause: Error; - readonly status: number; - - constructor(error: Error, status: number) { - super(error.message); - this.name = "ResponseParseError"; - this.cause = error; - this.status = status; - } -} - -const asError = (error: unknown): Error => (error instanceof Error ? error : new Error(String(error))); -const percentage = (value: number): number => Math.max(0, Math.min(100, Math.round(value))); - -const publicFile = (file: { id: string; name?: string; type?: string; size?: number | null }): FileUploadFile => ({ - id: file.id, - name: file.name ?? "", - ...(file.type ? { type: file.type } : {}), - ...(typeof file.size === "number" ? { size: file.size } : {}), -}); +import { UploadController } from "./UploadController"; +import { UppyContextProvider, useDropzone, useFileInput } from "./uppyHeadless"; interface FileSelectionProps { + buttonRef: React.Ref; buttonDescriptionIds: string | undefined; disabled: boolean; - labels: FileUploadProps["labels"]; + labels: FileUploadLabels; } -const FileSelection = ({ buttonDescriptionIds, disabled, labels }: FileSelectionProps) => { +const FileSelection = ({ buttonRef, buttonDescriptionIds, disabled, labels }: FileSelectionProps) => { const [dragging, setDragging] = React.useState(false); const dragEntryCount = React.useRef(0); const handleDragEnter = React.useCallback(() => { @@ -105,12 +72,14 @@ const FileSelection = ({ buttonDescriptionIds, disabled, labels }: FileSelection
+ 0} + intent={state.allSuccessful ? "success" : undefined} + />
- {labels.completedFiles(uploadState.completed, uploadState.total)} + {labels.completedFiles(state.completed, files.length)}
)} -
- {uploadFiles.map((uploadFile) => ( +
+ {files.map((row) => (
- {uploadFile.file.name} - -
-
-
+
))}
@@ -482,18 +316,23 @@ function FileUploadInner( {instructions}
)} - {inlineError && ( + {error && ( )} - {selectionStatus &&
{selectionStatus}
} +
+ {announcement} +
); } -export const FileUpload = React.forwardRef(FileUploadInner) as ( - props: FileUploadProps & React.RefAttributes, -) => React.JSX.Element; +interface FileUploadComponent { + (props: FileUploadTextProps & React.RefAttributes>): React.JSX.Element; + (props: FileUploadParsedProps & React.RefAttributes>): React.JSX.Element; +} +// React.forwardRef cannot preserve an overloaded generic call signature. +export const FileUpload = React.forwardRef(FileUploadInner) as FileUploadComponent; export default FileUpload; diff --git a/src/components/FileUpload/fileupload.scss b/src/components/FileUpload/fileupload.scss index 3337802b..bbf10801 100644 --- a/src/components/FileUpload/fileupload.scss +++ b/src/components/FileUpload/fileupload.scss @@ -93,11 +93,25 @@ } } +.#{$eccgui}-fileupload__progress-actions { + display: flex; + flex-shrink: 0; + gap: $eccgui-size-inline-whitespace; + align-items: center; +} + .#{$eccgui}-fileupload__completed-files { font-size: $eccgui-size-typo-caption; color: eccgui-color-rgba($eccgui-color-applicationheader-text, $eccgui-opacity-muted); } +.#{$eccgui}-fileupload__cancelled-status { + display: flex; + gap: 0.5 * $eccgui-size-inline-whitespace; + align-items: center; + color: $eccgui-color-warning-text; +} + .#{$eccgui}-fileupload__error { color: $eccgui-color-danger-text; } diff --git a/src/components/FileUpload/types.ts b/src/components/FileUpload/types.ts index 37d29da3..c22b97cb 100644 --- a/src/components/FileUpload/types.ts +++ b/src/components/FileUpload/types.ts @@ -1,6 +1,11 @@ -export interface FileUploadHandle { - upload(): Promise; +export interface FileUploadHandle { + /** Starts the current selection; overlapping calls share a result. */ + upload(): Promise>; + /** Cancels pending work and clears the selection, retaining inline errors. */ cancel(): void; + /** Removes a local row and pending work; never deletes a server resource. */ + remove(fileId: string): void; + /** Cancels pending work and clears selection, progress and errors. */ reset(): void; } @@ -17,7 +22,7 @@ export interface FileUploadResponse { file: FileUploadFile; } -export type FileUploadErrorKind = "restriction" | "response" | "transport"; +export type FileUploadErrorKind = "restriction" | "validation" | "response" | "transport"; export interface FileUploadError { kind: FileUploadErrorKind; @@ -26,18 +31,42 @@ export interface FileUploadError { status?: number; } +export interface FileUploadState { + pendingApproval: number; + queued: number; + uploading: number; + completed: number; + failed: number; + cancelled: number; + progress: number; + allSuccessful: boolean; +} + +/** A snapshot of retained files, not a delta from the previous completion. */ +export interface FileUploadResult { + reason: "settled" | "cancelled"; + successful: readonly FileUploadResponse[]; + failed: readonly FileUploadError[]; + cancelled: readonly FileUploadFile[]; +} + export interface FileUploadLabels { + cancelFile: string; + continueUpload: string; dropHereOr: string; browse: string; uploadProgress: string; overallUploadProgress: string; fileUploadProgress: (file: FileUploadFile) => string; completedFiles: (completed: number, total: number) => string; - selectedFile?: (file: FileUploadFile) => string; - uploadedFile?: (file: FileUploadFile) => string; - restrictionError?: (error: Error, file?: FileUploadFile) => string; - responseError?: (error: Error, file?: FileUploadFile) => string; - transportError?: (error: Error, file?: FileUploadFile) => string; + retry: string; + removeFile: string; + removedFile: (file: FileUploadFile) => string; + stopUploads: string; + uploadCancelled: string; + selectedFile: (file: FileUploadFile) => string; + uploadedFile: (file: FileUploadFile) => string; + formatError: (error: FileUploadError) => string; } export interface FileUploadResponseMetadata { @@ -47,37 +76,42 @@ export interface FileUploadResponseMetadata { export type FileUploadEndpoint = string | ((file: FileUploadFile) => string); export type FileUploadHeaders = Record | (() => Record); +export type FileUploadApproval = (file: FileUploadFile, signal: AbortSignal) => Promise; -interface FileUploadBaseProps { - /** Stable ID for the widget. A unique ID is generated when omitted. */ +export interface FileUploadBaseProps { id?: string; - /** Localized accessible name, displayed as the widget label by default. */ + /** Localized widget label and accessible name. */ name: string; - /** Hides the widget label visually while retaining it as its accessible name. */ hideName?: boolean; - /** Localized selection labels. */ labels: FileUploadLabels; - /** Additional localized file restrictions or interaction instructions. */ instructions?: string; endpoint: FileUploadEndpoint; acceptedFileTypes?: string[]; maxFileSize?: number; - maxNumberOfFiles?: number; - /** Maximum number of files uploaded at the same time. Defaults to 1. */ + /** Maximum retained incomplete files. Defaults to 1; null removes the restriction. */ + maxNumberOfFiles?: number | null; + /** Maximum active requests across selections and retries. Defaults to 1. */ concurrency?: number; autoUpload?: boolean; + /** Initialized once per mount; reset/rerender does not add these files again. */ + initialFiles?: readonly File[]; + beforeUpload?: FileUploadApproval; method?: "POST" | "PUT"; headers?: FileUploadHeaders; - onUploadStart?: () => void; - onUploadProgress?: (percentage: number) => void; + /** Selection notification only. Use beforeUpload for asynchronous approval. */ + onFilesAdded?: (files: FileUploadFile[]) => void; onUploadSuccess?: (response: FileUploadResponse) => void; onUploadError?: (error: FileUploadError) => void; - onUploadEnd?: () => void; + onStateChange?: (state: Readonly) => void; + onComplete?: (result: FileUploadResult) => void; + /** Blocks selection and future request starts; cancellation/removal remain available. */ disabled?: boolean; + /** Blocks only picker/drop interaction. */ + selectionDisabled?: boolean; } -type FileUploadParserProps = [T] extends [string] - ? { parseResponse?: (metadata: FileUploadResponseMetadata) => T } - : { parseResponse: (metadata: FileUploadResponseMetadata) => T }; - -export type FileUploadProps = FileUploadBaseProps & FileUploadParserProps; +export type FileUploadTextProps = FileUploadBaseProps & { parseResponse?: undefined }; +export type FileUploadParsedProps = FileUploadBaseProps & { + parseResponse: (metadata: FileUploadResponseMetadata) => T; +}; +export type FileUploadProps = FileUploadParsedProps | (string extends T ? FileUploadTextProps : never); diff --git a/src/components/FileUpload/uppyHeadless.ts b/src/components/FileUpload/uppyHeadless.ts index 5366a2de..cef08ecf 100644 --- a/src/components/FileUpload/uppyHeadless.ts +++ b/src/components/FileUpload/uppyHeadless.ts @@ -1,104 +1,20 @@ -import React from "react"; import UppyCore from "@uppy/core"; +import type BasePlugin from "@uppy/core/lib/BasePlugin"; +import type Uppy5 from "@uppy/core/lib/Uppy"; import * as UppyReact from "@uppy/react"; +import type * as UppyReact5 from "@uppy/react/lib/index"; import XHRUploadCore from "@uppy/xhr-upload"; - -export interface HeadlessUppyFile { - id: string; - name?: string; - type?: string; - size?: number | null; -} - -interface HeadlessUppyRestrictions { - allowedFileTypes?: string[]; - maxFileSize?: number; - maxNumberOfFiles?: number; -} - -interface HeadlessUppyOptions { - id: string; - autoProceed: boolean; - restrictions: HeadlessUppyRestrictions; -} - -export interface HeadlessUploadResponse { - body: unknown; - status: number; -} - -export interface HeadlessFileProgress { - bytesTotal: number | null; - bytesUploaded: number; -} - -interface HeadlessUppyEvents { - "cancel-all": () => void; - complete: (result: { failed: HeadlessUppyFile[]; successful: HeadlessUppyFile[] }) => void; - "file-added": (file: HeadlessUppyFile) => void; - progress: (percentage: number) => void; - "restriction-failed": (file: HeadlessUppyFile | undefined, error: Error) => void; - upload: (uploadId: string, files: HeadlessUppyFile[]) => void; - "upload-error": (file: HeadlessUppyFile | undefined, error: Error, response?: XMLHttpRequest) => void; - "upload-progress": (file: HeadlessUppyFile | undefined, progress: HeadlessFileProgress) => void; - "upload-success": (file: HeadlessUppyFile | undefined, response: HeadlessUploadResponse) => void; -} - -interface HeadlessXhrUploadOptions { - endpoint: (file: HeadlessUppyFile) => string; - getResponseData: (xhr: XMLHttpRequest) => unknown; - headers: (file: HeadlessUppyFile) => Record; - limit: number; - method: "POST" | "PUT"; -} - -interface HeadlessXhrUploadConstructor { - new (...args: unknown[]): unknown; -} - -export interface HeadlessUppy { - cancelAll(): void; - destroy(): void; - getPlugin(id: "XHRUpload"): { setOptions(options: Pick): void } | undefined; - off(event: Event, callback: HeadlessUppyEvents[Event]): void; - on(event: Event, callback: HeadlessUppyEvents[Event]): void; - setOptions(options: { autoProceed?: boolean; restrictions?: HeadlessUppyRestrictions }): void; - upload(): Promise; - use(plugin: HeadlessXhrUploadConstructor, options: HeadlessXhrUploadOptions): HeadlessUppy; -} - -interface HeadlessUppyConstructor { - new (options: HeadlessUppyOptions): HeadlessUppy; -} - -interface DropzoneRootProps { - onDragEnter: React.DragEventHandler; - onDragLeave: React.DragEventHandler; - onDragOver: React.DragEventHandler; - onDrop: React.DragEventHandler; -} - -interface FileInputProps { - accept?: string; - id: string; - multiple: boolean; - onChange: React.ChangeEventHandler; - type: "file"; -} - -interface UppyReactHeadless { - UppyContextProvider: React.ComponentType<{ children: React.ReactNode; uppy: HeadlessUppy }>; - useDropzone(options: { noClick: boolean; onDragEnter: () => void; onDragLeave: () => void; onDrop: () => void }): { - getRootProps(): DropzoneRootProps; - }; - useFileInput(): { - getButtonProps(): { onClick: React.MouseEventHandler; type: "button" }; - getInputProps(): FileInputProps; - }; -} - -// Source consumers can still hoist legacy Uppy declarations during the staged migration. -// Runtime package resolution remains on gui-elements' pinned Uppy 5 dependencies. -export const Uppy = UppyCore as unknown as HeadlessUppyConstructor; -export const XHRUpload = XHRUploadCore as unknown as HeadlessXhrUploadConstructor; -export const { UppyContextProvider, useDropzone, useFileInput } = UppyReact as unknown as UppyReactHeadless; +import type { XhrUploadOpts } from "@uppy/xhr-upload/lib/index"; + +// Legacy Node-style TypeScript resolution misses Uppy 5's exports and falls back to hoisted +// Uppy 1 declarations. Resolve the pinned types explicitly until the legacy consumers migrate. +export const Uppy = UppyCore as unknown as typeof Uppy5; +type UploadOptions = XhrUploadOpts, UploadBody> & { id?: string }; +export const XHRUpload = XHRUploadCore as unknown as { + new (uppy: UploadUppy, options: UploadOptions): BasePlugin, UploadBody>; +}; +export const { UppyContextProvider, useDropzone, useFileInput } = UppyReact as unknown as typeof UppyReact5; + +export type UploadBody = { value: unknown }; +export type UploadUppy = Uppy5, UploadBody>; +export type UploadUppyFile = ReturnType[number]; From 2eee4dc4e563d8ab875d45ac0f0a86e01d62d198 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Thu, 17 Sep 2026 16:43:40 +0200 Subject: [PATCH 08/12] Add comments for scripts in package.json --- .typescript/tscheck-fileupload.json | 4 + package.json | 25 + scripts/type-tests/file-upload.tsx | 54 ++ src/components/FileUpload/UploadController.ts | 500 ++++++++++++++++++ src/components/FileUpload/testHelpers.ts | 130 +++++ 5 files changed, 713 insertions(+) create mode 100644 .typescript/tscheck-fileupload.json create mode 100644 scripts/type-tests/file-upload.tsx create mode 100644 src/components/FileUpload/UploadController.ts create mode 100644 src/components/FileUpload/testHelpers.ts diff --git a/.typescript/tscheck-fileupload.json b/.typescript/tscheck-fileupload.json new file mode 100644 index 00000000..0b358270 --- /dev/null +++ b/.typescript/tscheck-fileupload.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["../scripts/type-tests/file-upload.tsx", "../declarations.d.ts"] +} diff --git a/package.json b/package.json index 753d7eb0..5e675198 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,31 @@ "src", "scripts" ], + "//": [ + "project:clean: Remove generated test results, coverage, package builds, Storybook output and the Yarn error log.", + "build:clean: Remove compiled package output from dist/.", + "build:esm: Build the ESM package and TypeScript declarations, then repair generated module imports.", + "build:cjs: Build the CommonJS package and apply compatibility fixes.", + "build:all: Clean dist/ and build both ESM and CommonJS packages.", + "compile: Type-check the library without emitting JavaScript.", + "compile-scss: Compile src/index.scss to validate styles; pass --outputCss to print the generated CSS.", + "storybook: Start the Storybook development server on port 6006 without opening a browser.", + "build-storybook: Build static Storybook output and generate Webpack statistics.", + "storybook:clean: Remove generated Storybook output.", + "test: Run Jest tests; pass --watch for interactive watch mode.", + "test:ci: Run Jest in CI mode with the default reporter.", + "test:types: Check the FileUpload API's compile-time type contracts.", + "test:coverage: Run Jest tests and collect coverage.", + "test:generate-output: Run Jest and write JSON results to .jest-test-results.json.", + "test:package:esm: Smoke-test the existing ESM build by importing the package root and rendering FileUpload.", + "test:package:cjs: Smoke-test the existing CommonJS build by loading the package root and rendering FileUpload.", + "test:package: Check API types, rebuild both package formats and run their root-export smoke tests.", + "test:clean: Remove generated Jest JSON results and coverage output.", + "autolint:scripts: Run ESLint with automatic fixes; lint failures do not fail this command.", + "autolint:styles: Run Stylelint with automatic fixes; lint failures do not fail this command.", + "autolint:prettier: Format supported project files in place with Prettier.", + "autolint:all: Run script/style autofixes followed by Prettier formatting." + ], "scripts": { "project:clean": "yarn test:clean && yarn build:clean && yarn storybook:clean && rimraf yarn-error.log", "build:clean": "rimraf dist/", diff --git a/scripts/type-tests/file-upload.tsx b/scripts/type-tests/file-upload.tsx new file mode 100644 index 00000000..e41d1261 --- /dev/null +++ b/scripts/type-tests/file-upload.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +import { FileUpload, FileUploadHandle, FileUploadLabels, FileUploadProps } from "../../src/components/FileUpload"; + +declare const labels: FileUploadLabels; +const common = { name: "Upload", endpoint: "/upload", labels }; +const textRef = React.createRef>(); +const parsedRef = React.createRef>(); + +export const textUpload = body.toUpperCase()} />; +export const parsedUpload = ( + (status === 204 ? null : { id: 42 })} + onUploadSuccess={({ body }) => body?.id.toFixed()} + /> +); +export const undefinedUpload = ( + undefined} + onUploadSuccess={({ body }) => { + const value: undefined = body; + return value; + }} + /> +); +export const literalUpload = ( + "ok" as const} + onUploadSuccess={({ body }) => { + const value: "ok" = body; + return value; + }} + /> +); + +// @ts-expect-error A narrower string response requires a parser. +export const invalidLiteral = {...common} />; +// @ts-expect-error Generic response props must require a parser too. +export const invalidProps: FileUploadProps<"ok"> = common; +// @ts-expect-error Parser-free responses cannot use a structured-response ref. +export const invalidRef = ; +export const invalidCallback = ( + // @ts-expect-error The parser determines the success body's type. + 42} onUploadSuccess={({ body }) => body.toUpperCase()} /> +); + +export async function completionTypes() { + const result = await parsedRef.current?.upload(); + return result?.successful.map(({ body }) => body?.id.toFixed()); +} diff --git a/src/components/FileUpload/UploadController.ts b/src/components/FileUpload/UploadController.ts new file mode 100644 index 00000000..10bd69f5 --- /dev/null +++ b/src/components/FileUpload/UploadController.ts @@ -0,0 +1,500 @@ +import { + FileUploadError, + FileUploadFile, + FileUploadHandle, + FileUploadParsedProps, + FileUploadResponse, + FileUploadResult, + FileUploadState, +} from "./types"; +import { UploadBody, UploadUppy, UploadUppyFile, Uppy, XHRUpload } from "./uppyHeadless"; + +type EntryState = + | { status: "pendingApproval"; controller: AbortController } + | { status: "queued"; order: number } + | { status: "uploading"; transportId: string } + | { status: "complete"; response: FileUploadResponse } + | { status: "error"; error: FileUploadError } + | { status: "cancelled" }; + +interface Entry { + file: FileUploadFile; + data: Blob; + selectionKey: string; + approved: boolean; + bytesUploaded: number; + state: EntryState; +} + +export interface UploadRow { + file: FileUploadFile; + status: EntryState["status"]; + progress: number; +} + +interface UploadView { + files: readonly UploadRow[]; + state: Readonly; + error?: FileUploadError; + announcement: string; +} + +class ResponseParseError extends Error { + readonly cause: Error; + readonly status: number; + + constructor(cause: Error, status: number) { + super(cause.message); + this.cause = cause; + this.status = status; + this.name = "ResponseParseError"; + } +} + +const asError = (value: unknown): Error => (value instanceof Error ? value : new Error(String(value))); +const percentage = (value: number) => Math.max(0, Math.min(100, Math.round(value))); +const publicFile = (file: UploadUppyFile): FileUploadFile => ({ + id: file.id, + name: file.name ?? "", + type: file.type, + size: file.size ?? undefined, +}); + +/** Owns the selection and scheduler. Uppy holds only files currently handed to transport. */ +export class UploadController implements FileUploadHandle { + readonly uppy: UploadUppy; + private readonly props: () => FileUploadParsedProps; + private entries = new Map(); + private listeners = new Set<() => void>(); + private sequence = 0; + private dispatchId?: string; + private scheduled = false; + private disposed = false; + private attached = true; + private enabled = false; + private batch?: { + promise: Promise>; + resolve: (result: FileUploadResult) => void; + }; + private view: UploadView = { + files: [], + announcement: "", + state: { + pendingApproval: 0, + queued: 0, + uploading: 0, + completed: 0, + failed: 0, + cancelled: 0, + progress: 0, + allSuccessful: false, + }, + }; + + constructor(id: string, props: () => FileUploadParsedProps) { + this.props = props; + this.uppy = new Uppy, UploadBody>({ + id, + autoProceed: false, + onBeforeFileAdded: (file) => (this.dispatchId ? { ...file, id: this.dispatchId } : file), + }).use(XHRUpload, { + limit: Number.POSITIVE_INFINITY, + endpoint: (file) => { + const selected = Array.isArray(file) ? undefined : this.transportEntry(file.id); + if (!selected) throw new Error("Upload was cancelled"); + const endpoint = this.props().endpoint; + return typeof endpoint === "function" ? endpoint(selected.file) : endpoint; + }, + headers: () => { + const headers = this.props().headers; + return typeof headers === "function" ? headers() : (headers ?? {}); + }, + getResponseData: (xhr) => { + try { + // The envelope prevents Uppy from substituting its parser for a null/undefined result. + return { + value: this.props().parseResponse({ status: xhr.status, responseText: xhr.responseText }), + }; + } catch (error) { + throw new ResponseParseError(asError(error), xhr.status); + } + }, + }); + this.uppy.on("files-added", this.filesAdded); + this.uppy.on("restriction-failed", (file, error) => { + if (!this.dispatchId) + this.reportError({ kind: "restriction", error, file: file ? publicFile(file) : undefined }); + }); + this.uppy.on("upload-progress", (file, progress) => { + const entry = file && this.transportEntry(file.id); + if (!entry) return; + entry.bytesUploaded = Math.min(entry.file.size ?? progress.bytesUploaded, progress.bytesUploaded); + this.publish(); + }); + this.uppy.on("upload-success", (file, response) => { + const entry = file && this.transportEntry(file.id); + if (!entry) return; + const result = { file: entry.file, status: response.status, body: response.body.value }; + entry.state = { status: "complete", response: result }; + entry.bytesUploaded = entry.file.size ?? 0; + this.uppy.removeFile(file.id); + this.view = { ...this.view, announcement: this.props().labels.uploadedFile(entry.file) }; + this.publish(); + if (!this.disposed && this.entries.get(entry.file.id) === entry) this.props().onUploadSuccess?.(result); + this.schedule(); + }); + this.uppy.on("upload-error", (file, error, response) => { + const entry = file && this.transportEntry(file.id); + if (!entry) return; + const parseError = error instanceof ResponseParseError; + this.fail(entry, { + kind: parseError ? "response" : "transport", + error, + file: entry.file, + status: parseError ? error.status : response?.status, + }); + }); + this.configure(); + } + + getSnapshot = (): UploadView => this.view; + attach = (): (() => void) => { + this.attached = true; + return () => { + this.attached = false; + // React StrictMode reattaches effects synchronously. Suppress callbacks immediately, + // but only destroy the transport if this is an actual unmount. + queueMicrotask(() => { + if (!this.attached) this.dispose(); + }); + }; + }; + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + configure = (): void => { + const props = this.props(); + this.uppy.setOptions({ + restrictions: { + allowedFileTypes: props.acceptedFileTypes, + maxFileSize: props.maxFileSize, + maxNumberOfFiles: props.maxNumberOfFiles === null ? undefined : (props.maxNumberOfFiles ?? 1), + }, + }); + this.uppy.getPlugin("XHRUpload")?.setOptions({ method: props.method ?? "POST" }); + this.schedule(); + }; + + addInitialFiles = (files: readonly File[]): void => { + this.uppy.addFiles(files.map((data) => ({ data, name: data.name, type: data.type }))); + }; + + private filesAdded = (files: UploadUppyFile[]): void => { + if (this.dispatchId || this.disposed || files.length === 0) return; + // Detach selection from transport immediately. Approval and manual mode never hold XHR slots. + files.forEach((file) => this.uppy.removeFile(file.id)); + const retained = [...this.entries.values()].filter((entry) => entry.state.status !== "complete"); + const maximum = this.props().maxNumberOfFiles === null ? Infinity : (this.props().maxNumberOfFiles ?? 1); + if (retained.length + files.length > maximum) { + this.reportError({ + kind: "restriction", + error: new Error(this.uppy.i18n("youCanOnlyUploadX", { smart_count: maximum })), + }); + return; + } + const accepted: Entry[] = []; + for (const file of files) { + if (file.isRemote) continue; + if (retained.some((entry) => entry.selectionKey === file.id)) { + this.reportError({ + kind: "restriction", + file: publicFile(file), + error: new Error(this.uppy.i18n("noDuplicates", { fileName: file.name ?? "" })), + }); + continue; + } + const entry: Entry = { + file: { ...publicFile(file), id: `${file.id}-${++this.sequence}` }, + data: file.data, + selectionKey: file.id, + approved: false, + bytesUploaded: 0, + state: { status: "pendingApproval", controller: new AbortController() }, + }; + this.entries.set(entry.file.id, entry); + accepted.push(entry); + } + if (!accepted.length) return; + this.openBatch(); + this.view = { + ...this.view, + error: undefined, + announcement: accepted.map((entry) => this.props().labels.selectedFile(entry.file)).join(". "), + }; + this.publish(); + this.props().onFilesAdded?.(accepted.map((entry) => entry.file)); + accepted.forEach((entry) => this.approve(entry)); + }; + + private approve(entry: Entry): void { + if (entry.state.status !== "pendingApproval") return; + const { controller } = entry.state; + const isCurrent = () => + this.attached && !this.disposed && !controller.signal.aborted && this.entries.get(entry.file.id) === entry; + const accept = (approved: boolean) => { + if (!isCurrent()) return; + if (approved) { + entry.approved = true; + entry.state = { status: "queued", order: ++this.sequence }; + } else { + this.entries.delete(entry.file.id); + } + this.publish(); + this.schedule(); + }; + const beforeUpload = this.props().beforeUpload; + if (!beforeUpload) { + accept(true); + return; + } + Promise.resolve() + .then(() => (isCurrent() ? beforeUpload(entry.file, controller.signal) : false)) + .then(accept, (error) => { + if (isCurrent()) this.fail(entry, { kind: "validation", error: asError(error), file: entry.file }); + }); + } + + private openBatch(): void { + if (this.batch) return; + let resolve!: (result: FileUploadResult) => void; + const promise = new Promise>((done) => { + resolve = done; + }); + this.batch = { promise, resolve }; + this.enabled = this.props().autoUpload !== false; + } + + upload = (): Promise> => { + if (this.props().disabled) return Promise.reject(new Error("File upload is disabled")); + if (!this.batch) return Promise.resolve(this.result("settled")); + this.enabled = true; + this.schedule(); + return this.batch.promise; + }; + + private schedule(): void { + if (this.scheduled || this.disposed) return; + this.scheduled = true; + queueMicrotask(() => { + this.scheduled = false; + if (this.disposed || !this.attached) return; + const props = this.props(); + if (!props.disabled && (this.enabled || props.autoUpload !== false)) { + const concurrency = Math.max(1, props.concurrency ?? 1); + const ready = [...this.entries.values()] + .filter((entry) => entry.state.status === "queued") + .sort( + (a, b) => + (a.state.status === "queued" ? a.state.order : 0) - + (b.state.status === "queued" ? b.state.order : 0), + ); + for (const entry of ready) { + if (this.disposed || this.props().disabled || this.view.state.uploading >= concurrency) break; + if (this.entries.get(entry.file.id) === entry && entry.state.status === "queued") this.start(entry); + } + } + this.settle(); + }); + } + + private start(entry: Entry): void { + const transportId = `${entry.file.id}-request-${++this.sequence}`; + entry.state = { status: "uploading", transportId }; + this.dispatchId = transportId; + try { + this.uppy.addFile({ data: entry.data, name: entry.file.name, type: entry.file.type }); + } catch (error) { + this.fail(entry, { kind: "restriction", error: asError(error), file: entry.file }); + return; + } finally { + this.dispatchId = undefined; + } + this.publish(); + if (this.transportEntry(transportId) !== entry) return; + void this.uppy.upload().catch((error) => { + if (this.transportEntry(transportId) === entry) { + this.fail(entry, { kind: "transport", error: asError(error), file: entry.file }); + } + }); + } + + private transportEntry(id: string): Entry | undefined { + if (this.disposed || !this.attached) return undefined; + return [...this.entries.values()].find( + (entry) => entry.state.status === "uploading" && entry.state.transportId === id, + ); + } + + private interrupt(entry: Entry): void { + const previous = entry.state; + entry.state = { status: "cancelled" }; + entry.bytesUploaded = 0; + if (previous.status === "pendingApproval") previous.controller.abort(); + if (previous.status === "uploading") this.uppy.removeFile(previous.transportId); + } + + cancelFile = (id: string): void => { + const entry = this.entries.get(id); + if (!entry || entry.state.status === "complete" || entry.state.status === "cancelled") return; + this.interrupt(entry); + this.publish(); + this.schedule(); + }; + + stop = (): void => { + for (const entry of this.entries.values()) { + if (entry.state.status !== "complete" && entry.state.status !== "error") this.interrupt(entry); + } + this.publish(); + this.settle(); + }; + + retry = (id: string): void => { + const entry = this.entries.get(id); + if (this.props().disabled || !entry || !["error", "cancelled"].includes(entry.state.status)) return; + this.openBatch(); + this.enabled = true; + entry.bytesUploaded = 0; + entry.state = entry.approved + ? { status: "queued", order: ++this.sequence } + : { status: "pendingApproval", controller: new AbortController() }; + this.view = { ...this.view, error: undefined }; + this.approve(entry); + this.publish(); + this.schedule(); + }; + + continue = (): void => { + for (const entry of this.entries.values()) if (entry.state.status === "cancelled") this.retry(entry.file.id); + }; + + remove = (id: string): void => { + const entry = this.entries.get(id); + if (!entry) return; + this.entries.delete(id); + this.interrupt(entry); + this.view = { ...this.view, announcement: this.props().labels.removedFile(entry.file) }; + this.publish(); + this.schedule(); + }; + + cancel = (): void => { + for (const entry of this.entries.values()) if (entry.state.status !== "complete") this.interrupt(entry); + const result = this.result("cancelled"); + const batch = this.batch; + this.batch = undefined; + this.entries.clear(); + this.view = { ...this.view, announcement: "" }; + this.publish(); + batch?.resolve(result); + if (batch && !this.disposed) this.props().onComplete?.(result); + }; + + reset = (): void => { + this.view = { ...this.view, error: undefined }; + this.cancel(); + }; + + dispose = (): void => { + this.disposed = true; + this.cancel(); + this.listeners.clear(); + this.uppy.destroy(); + }; + + private fail(entry: Entry, error: FileUploadError): void { + this.interrupt(entry); + entry.state = { status: "error", error }; + this.reportError(error); + this.schedule(); + } + + private reportError(error: FileUploadError): void { + if (this.disposed || !this.attached) return; + this.view = { ...this.view, error }; + this.publish(); + this.props().onUploadError?.(error); + } + + private result(reason: FileUploadResult["reason"]): FileUploadResult { + const successful: FileUploadResponse[] = []; + const failed: FileUploadError[] = []; + const cancelled: FileUploadFile[] = []; + for (const entry of this.entries.values()) { + if (entry.state.status === "complete") successful.push(entry.state.response); + if (entry.state.status === "error") failed.push(entry.state.error); + if (entry.state.status === "cancelled") cancelled.push(entry.file); + } + return { reason, successful, failed, cancelled }; + } + + private settle(): void { + const { pendingApproval, queued, uploading } = this.view.state; + if (!this.batch || pendingApproval || queued || uploading) return; + const batch = this.batch; + this.batch = undefined; + const result = this.result("settled"); + batch.resolve(result); + this.props().onComplete?.(result); + } + + private publish(): void { + if (this.disposed || !this.attached) return; + const state: FileUploadState = { + pendingApproval: 0, + queued: 0, + uploading: 0, + completed: 0, + failed: 0, + cancelled: 0, + progress: 0, + allSuccessful: false, + }; + let bytesTotal = 0, + bytesUploaded = 0, + approved = 0; + const files = [...this.entries.values()].map((entry) => { + const { status } = entry.state; + if (status === "complete") state.completed++; + else if (status === "error") state.failed++; + else state[status]++; + if (entry.approved) { + approved++; + bytesTotal += entry.file.size ?? 0; + bytesUploaded += entry.bytesUploaded; + } + return { + file: entry.file, + status, + progress: + status === "complete" + ? 100 + : entry.file.size + ? percentage((entry.bytesUploaded / entry.file.size) * 100) + : 0, + }; + }); + state.progress = bytesTotal + ? percentage((bytesUploaded / bytesTotal) * 100) + : approved + ? percentage((state.completed / approved) * 100) + : 0; + state.allSuccessful = files.length > 0 && state.completed === files.length; + const changed = JSON.stringify(state) !== JSON.stringify(this.view.state); + this.view = { ...this.view, files, state }; + this.listeners.forEach((listener) => listener()); + if (changed) this.props().onStateChange?.(state); + } +} diff --git a/src/components/FileUpload/testHelpers.ts b/src/components/FileUpload/testHelpers.ts new file mode 100644 index 00000000..23990215 --- /dev/null +++ b/src/components/FileUpload/testHelpers.ts @@ -0,0 +1,130 @@ +import { act } from "@testing-library/react"; +import { FileUploadLabels } from "./types"; + +export const labels: FileUploadLabels = { + cancelFile: "Cancel upload", + continueUpload: "Continue uploads", + browse: "browse files", + dropHereOr: "Drop files here or", + uploadProgress: "Upload progress", + overallUploadProgress: "Overall upload progress", + fileUploadProgress: (file) => `Upload progress for ${file.name}`, + completedFiles: (completed, total) => `${completed} of ${total} files completed`, + retry: "Retry", + stopUploads: "Stop uploads", + uploadCancelled: "Upload cancelled", + removeFile: "Remove", + removedFile: (file) => `${file.name} removed`, + selectedFile: (file) => `${file.name} selected`, + uploadedFile: (file) => `${file.name} uploaded`, + formatError: ({ kind, error }) => + kind === "response" ? `Invalid response: ${error.message}` : `Upload failed: ${error.message}`, +}; + +export class ControlledXMLHttpRequest { + static requests: ControlledXMLHttpRequest[] = []; + + method = ""; + url = ""; + requestBody: Document | XMLHttpRequestBodyInit | null = null; + requestHeaders: Record = {}; + response: unknown; + responseText = ""; + responseType: XMLHttpRequestResponseType = ""; + status = 0; + statusText = ""; + withCredentials = false; + aborted = false; + sent = false; + onabort: (() => void | Promise) | null = null; + onerror: (() => void | Promise) | null = null; + onload: (() => void | Promise) | null = null; + upload = { onprogress: null as ((event: ProgressEvent) => void) | null }; + + constructor() { + ControlledXMLHttpRequest.requests.push(this); + } + + abort() { + this.aborted = true; + void this.onabort?.(); + } + + open(method: string, url: string) { + this.method = method; + this.url = url; + } + + send(body: Document | XMLHttpRequestBodyInit | null) { + this.requestBody = body; + this.sent = true; + } + + setRequestHeader(name: string, value: string) { + this.requestHeaders[name] = value; + } + + progress(loaded: number, total: number) { + this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent); + } + + async fail(message: string) { + this.statusText = message; + await this.onerror?.(); + } + + async respond(status: number, responseText: string) { + this.status = status; + this.responseText = responseText; + await this.onload?.(); + } +} + +class ControlledFormData { + append() {} +} + +const NativeXMLHttpRequest = global.XMLHttpRequest; +const NativeFormData = global.FormData; +const nativeAbortSignalAny = AbortSignal.any; + +const combineAbortSignals = (signals: AbortSignal[]): AbortSignal => { + const controller = new AbortController(); + signals.forEach((signal) => { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", () => controller.abort(), { once: true }); + }); + return controller.signal; +}; + +export const completeRequest = async (request: ControlledXMLHttpRequest, status: number, responseText: string) => { + await act(async () => { + await request.respond(status, responseText); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +export const failRequest = async (request: ControlledXMLHttpRequest, message: string) => { + await act(async () => { + await request.fail(message); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +beforeEach(() => { + ControlledXMLHttpRequest.requests = []; + global.XMLHttpRequest = ControlledXMLHttpRequest as unknown as typeof XMLHttpRequest; + window.XMLHttpRequest = ControlledXMLHttpRequest as unknown as typeof XMLHttpRequest; + global.FormData = ControlledFormData as unknown as typeof FormData; + window.FormData = ControlledFormData as unknown as typeof FormData; + AbortSignal.any = combineAbortSignals; +}); + +afterEach(() => { + jest.restoreAllMocks(); + global.XMLHttpRequest = NativeXMLHttpRequest; + window.XMLHttpRequest = NativeXMLHttpRequest; + global.FormData = NativeFormData; + window.FormData = NativeFormData; + AbortSignal.any = nativeAbortSignalAny; +}); From 61c4671a7eed18226ee9455166923155953a1152 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Thu, 17 Sep 2026 18:24:28 +0200 Subject: [PATCH 09/12] Improve FileUpload API usability and documentation - Add typed restriction details for localizing size, type, count and duplicate errors. - Support synchronous and asynchronous beforeUpload approval. - Document manual uploads, typed responses, overwrite approval and lifecycle semantics. - Extend runtime and compile-time tests for restrictions and approval. - Simplify the changelog --- CHANGELOG.md | 27 +-- README.md | 4 + scripts/type-tests/file-upload.tsx | 22 +- .../FileUpload/FileUpload.stories.tsx | 9 + .../FileUpload/FileUpload.transport.test.tsx | 129 ++++++++++ src/components/FileUpload/README.md | 221 ++++++++++++++++++ src/components/FileUpload/UploadController.ts | 35 ++- src/components/FileUpload/types.ts | 36 ++- 8 files changed, 456 insertions(+), 27 deletions(-) create mode 100644 src/components/FileUpload/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 525c683d..d9eba4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,25 +10,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - ` +

{message}

+ + ); +} +``` + +Localize the surrounding button and summary in your application too. Manual mode delays automatic +request starts, not approval checks. Retry and Continue are explicit user requests and can start +uploads in manual mode. + +## Typed responses + +Without a parser, response bodies are strings. A parser determines the body type of both +`onUploadSuccess` and completion results; no generic argument or cast is needed. Validate external +JSON at the boundary: + +```tsx +import { + FileUpload, + FileUploadLabels, + FileUploadResponseMetadata, +} from "@eccenca/gui-elements"; + +function parseReceipt({ responseText }: FileUploadResponseMetadata): { + id: string; +} { + const value: unknown = JSON.parse(responseText); + if ( + typeof value !== "object" || + value === null || + !("id" in value) || + typeof value.id !== "string" + ) { + throw new Error("Expected an upload receipt with a string id"); + } + return { id: value.id }; +} + +export function ReceiptUpload({ + labels, + onReceipt, +}: { + labels: FileUploadLabels; + onReceipt: (id: string) => void; +}) { + return ( + onReceipt(body.id)} + /> + ); +} +``` + +Parser exceptions produce inline errors with `kind: "response"`, not success callbacks. +Intentional `null` and `undefined` parser results are preserved. For a manual parsed upload, use +`FileUploadHandle>` as the ref type. + +## Approval and overwrite prompts + +`beforeUpload(file, signal)` runs after local restrictions, on selection—even with +`autoUpload={false}`. Return `true` to approve or `false` to decline without an error. Throwing or +rejecting produces a retriable `validation` error. A synchronous decision needs no `async` wrapper: + +```tsx + file.name !== "protected.ttl"} +/> +``` + +For overwrite approval, keep domain checks and dialog rendering in the application: + +```tsx +import { + FileUpload, + FileUploadFile, + FileUploadLabels, +} from "@eccenca/gui-elements"; + +interface OverwriteUploadProps { + labels: FileUploadLabels; + resourceExists: (name: string, signal: AbortSignal) => Promise; + // The application dialog must close and settle its promise when the signal aborts. + confirmReplace: ( + file: FileUploadFile, + signal: AbortSignal, + ) => Promise; +} + +export function OverwriteUpload({ + labels, + resourceExists, + confirmReplace, +}: OverwriteUploadProps) { + return ( + { + const exists = await resourceExists(file.name, signal); + if (signal.aborted) return false; + return !exists || (await confirmReplace(file, signal)); + }} + /> + ); +} +``` + +Checks for different files may run concurrently: one unanswered prompt does not block another +approved file. Handle prompts independently, keyed by `file.id`. Cancellation, removal, reset and +unmount abort pending approval signals; observe them to cancel checks and dismiss dialogs. Stale +decisions cannot start an upload. Approval is not a per-request hook: transport retries do not +repeat a successful approval, whereas retrying a failed approval runs the check again. + +## Localized restriction errors + +Use `labels.formatError` for display and `onUploadError` for notification/diagnostics. Both receive +the same discriminated `FileUploadError`. For `kind === "restriction"`, switch on +`error.restriction.code` instead of parsing `error.error.message`: + +| Code | Details | +| ------------------ | ------------------------------------------------------------------------------------- | +| `maxFileSize` | `maxFileSize` in bytes | +| `fileType` | `acceptedFileTypes`, an immutable snapshot of the configured extensions/MIME patterns | +| `maxNumberOfFiles` | `maxNumberOfFiles`, the incomplete-selection limit | +| `duplicate` | A file already present in the retained selection | +| `unknown` | Fallback for an unclassified rejection | + +`error.file` is optional (for example, a count rejection can apply to a selection). Include a generic +translation when no file is available. If both size and type fail, size takes precedence in the +structured detail. Diagnostic error text is not a localization contract. Other error kinds are +`validation`, `response` and `transport`. + +## Lifecycle and completion + +- `upload()` waits for the logical batch, including pending approvals. Overlapping calls share a + result; calling while idle returns the retained result. File failures and cancellations resolve + in the result, rather than rejecting the promise. `reason: "settled"` does not mean all files succeeded. +- `onComplete` fires once per settled/cancelled logical batch. Its result is cumulative for retained + rows, not a delta; earlier successes can appear again. Use `onUploadSuccess` for per-file domain + side effects so later batches do not repeat them. +- Files rejected during selection and declined approvals do not enter the result. If restrictions + change before a queued file starts, that file's rejection appears in `result.failed`. An empty result is not + proof that a file uploaded. Use `state.allSuccessful` to enable a “continue after upload” action; + transfer progress alone does not prove HTTP success. +- The visible Stop action retains cancelled rows. Continue retries remaining incomplete rows; + remove unwanted cancelled files first. `ref.cancel()` instead cancels work and clears the selection, + retaining inline errors. `ref.reset()` also clears progress and errors. +- `ref.remove(fileId)` removes local state and pending work, never a server resource. +- `initialFiles` is consumed once per mount through the usual restriction/approval path. Rerenders + and reset do not re-add these files. +- `disabled` prevents selection and future request starts, but cancellation/removal remain available. + `selectionDisabled` blocks only picker/drop interaction. diff --git a/src/components/FileUpload/UploadController.ts b/src/components/FileUpload/UploadController.ts index 10bd69f5..87b4f839 100644 --- a/src/components/FileUpload/UploadController.ts +++ b/src/components/FileUpload/UploadController.ts @@ -4,6 +4,7 @@ import { FileUploadHandle, FileUploadParsedProps, FileUploadResponse, + FileUploadRestriction, FileUploadResult, FileUploadState, } from "./types"; @@ -123,7 +124,12 @@ export class UploadController implements FileUploadHandle { this.uppy.on("files-added", this.filesAdded); this.uppy.on("restriction-failed", (file, error) => { if (!this.dispatchId) - this.reportError({ kind: "restriction", error, file: file ? publicFile(file) : undefined }); + this.reportError({ + kind: "restriction", + error, + file: file ? publicFile(file) : undefined, + restriction: this.restrictionDetails(error, file ? publicFile(file) : undefined), + }); }); this.uppy.on("upload-progress", (file, progress) => { const entry = file && this.transportEntry(file.id); @@ -200,6 +206,7 @@ export class UploadController implements FileUploadHandle { if (retained.length + files.length > maximum) { this.reportError({ kind: "restriction", + restriction: { code: "maxNumberOfFiles", maxNumberOfFiles: maximum }, error: new Error(this.uppy.i18n("youCanOnlyUploadX", { smart_count: maximum })), }); return; @@ -210,6 +217,7 @@ export class UploadController implements FileUploadHandle { if (retained.some((entry) => entry.selectionKey === file.id)) { this.reportError({ kind: "restriction", + restriction: { code: "duplicate" }, file: publicFile(file), error: new Error(this.uppy.i18n("noDuplicates", { fileName: file.name ?? "" })), }); @@ -316,7 +324,13 @@ export class UploadController implements FileUploadHandle { try { this.uppy.addFile({ data: entry.data, name: entry.file.name, type: entry.file.type }); } catch (error) { - this.fail(entry, { kind: "restriction", error: asError(error), file: entry.file }); + const diagnostic = asError(error); + this.fail(entry, { + kind: "restriction", + error: diagnostic, + file: entry.file, + restriction: this.restrictionDetails(diagnostic, entry.file), + }); return; } finally { this.dispatchId = undefined; @@ -330,6 +344,23 @@ export class UploadController implements FileUploadHandle { }); } + private restrictionDetails(error: Error, file?: FileUploadFile): FileUploadRestriction { + if (!("isRestriction" in error) || !error.isRestriction) return { code: "unknown" }; + const { maxFileSize, acceptedFileTypes, maxNumberOfFiles = 1 } = this.props(); + // Uppy is configured with only size/type (file) and count (selection) restrictions. + // Derive a violated rule from inputs, never from Uppy message text. If both size and type + // fail, report size first; after it is fixed, type validation still applies. + if (file) { + if (maxFileSize && file.size !== undefined && file.size > maxFileSize) { + return { code: "maxFileSize", maxFileSize }; + } + if (acceptedFileTypes) return { code: "fileType", acceptedFileTypes: [...acceptedFileTypes] }; + } else if (maxNumberOfFiles) { + return { code: "maxNumberOfFiles", maxNumberOfFiles }; + } + return { code: "unknown" }; + } + private transportEntry(id: string): Entry | undefined { if (this.disposed || !this.attached) return undefined; return [...this.entries.values()].find( diff --git a/src/components/FileUpload/types.ts b/src/components/FileUpload/types.ts index c22b97cb..da0ea73b 100644 --- a/src/components/FileUpload/types.ts +++ b/src/components/FileUpload/types.ts @@ -1,5 +1,9 @@ export interface FileUploadHandle { - /** Starts the current selection; overlapping calls share a result. */ + /** + * Starts/waits for the current logical batch; overlapping calls share a result. + * File failures and cancellation resolve in the result, rather than rejecting this promise. + * Calling while disabled rejects. An idle call resolves with the retained selection's result. + */ upload(): Promise>; /** Cancels pending work and clears the selection, retaining inline errors. */ cancel(): void; @@ -24,13 +28,27 @@ export interface FileUploadResponse { export type FileUploadErrorKind = "restriction" | "validation" | "response" | "transport"; -export interface FileUploadError { - kind: FileUploadErrorKind; +/** Machine-readable restriction details for localized messages; sizes are in bytes. */ +export type FileUploadRestriction = + | { code: "maxFileSize"; maxFileSize: number } + | { code: "fileType"; acceptedFileTypes: readonly string[] } + | { code: "maxNumberOfFiles"; maxNumberOfFiles: number } + | { code: "duplicate" } + | { code: "unknown" }; + +interface FileUploadErrorDetails { + /** Diagnostic error. Use restriction details, not this message, to localize restrictions. */ error: Error; file?: FileUploadFile; status?: number; } +export type FileUploadError = FileUploadErrorDetails & + ( + | { kind: "restriction"; restriction: FileUploadRestriction } + | { kind: Exclude; restriction?: never } + ); + export interface FileUploadState { pendingApproval: number; queued: number; @@ -76,7 +94,8 @@ export interface FileUploadResponseMetadata { export type FileUploadEndpoint = string | ((file: FileUploadFile) => string); export type FileUploadHeaders = Record | (() => Record); -export type FileUploadApproval = (file: FileUploadFile, signal: AbortSignal) => Promise; +/** True approves, false declines; throwing/rejecting produces a retriable validation error. */ +export type FileUploadApproval = (file: FileUploadFile, signal: AbortSignal) => boolean | Promise; export interface FileUploadBaseProps { id?: string; @@ -92,17 +111,24 @@ export interface FileUploadBaseProps { maxNumberOfFiles?: number | null; /** Maximum active requests across selections and retries. Defaults to 1. */ concurrency?: number; + /** Start approved files automatically. Defaults to true. */ autoUpload?: boolean; /** Initialized once per mount; reset/rerender does not add these files again. */ initialFiles?: readonly File[]; + /** + * Approve each file after selection and local restrictions, including in manual mode. + * This is not a per-request hook. Observe the signal to dismiss pending approval UI on cancellation. + */ beforeUpload?: FileUploadApproval; method?: "POST" | "PUT"; headers?: FileUploadHeaders; - /** Selection notification only. Use beforeUpload for asynchronous approval. */ + /** Selection notification only. Use beforeUpload for approval. */ onFilesAdded?: (files: FileUploadFile[]) => void; + /** Once per successful file; use this for domain side effects, not cumulative onComplete results. */ onUploadSuccess?: (response: FileUploadResponse) => void; onUploadError?: (error: FileUploadError) => void; onStateChange?: (state: Readonly) => void; + /** Once per settled/cancelled logical batch; includes retained earlier successes, failures and cancellations. */ onComplete?: (result: FileUploadResult) => void; /** Blocks selection and future request starts; cancellation/removal remain available. */ disabled?: boolean; From 67054a452e45529af68cddf85f1e329a8a7687c9 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Fri, 18 Sep 2026 12:25:03 +0200 Subject: [PATCH 10/12] Refactor FileUpload for clarity and maintainability - Extract controller lifecycle and focus management into hooks - Extract file-row rendering and clarify action conditions - Fix stale drag state after disabling selection or non-file drops - Add regression tests for drag-state cleanup --- src/components/FileUpload/FileUpload.test.tsx | 40 +++ src/components/FileUpload/FileUpload.tsx | 257 ++++++++---------- src/components/FileUpload/README.md | 8 + src/components/FileUpload/useRemovalFocus.ts | 47 ++++ .../FileUpload/useUploadController.ts | 60 ++++ 5 files changed, 273 insertions(+), 139 deletions(-) create mode 100644 src/components/FileUpload/useRemovalFocus.ts create mode 100644 src/components/FileUpload/useUploadController.ts diff --git a/src/components/FileUpload/FileUpload.test.tsx b/src/components/FileUpload/FileUpload.test.tsx index 384d5840..534b655e 100644 --- a/src/components/FileUpload/FileUpload.test.tsx +++ b/src/components/FileUpload/FileUpload.test.tsx @@ -205,6 +205,46 @@ describe("FileUpload", () => { expect(dropzone).toHaveAttribute("data-state", "idle"); }); + it.each(["disabled", "selectionDisabled"] as const)("clears an active drag when %s changes", (disabledProp) => { + const props = { autoUpload: false, name: "Project file upload", endpoint: "/files", labels }; + const { rerender } = render(); + const dropzone = document.querySelector('[data-dropzone-for="Files"]')!; + + fireEvent.dragEnter(dropzone); + expect(dropzone).toHaveAttribute("data-state", "dragging"); + + rerender(); + expect(dropzone).toHaveAttribute("data-state", "disabled"); + expect(dropzone.className).not.toContain("--dragging"); + rerender(); + expect(dropzone).toHaveAttribute("data-state", "idle"); + + rerender(); + fireEvent.drop(dropzone, { dataTransfer: { files: [new File(["data"], "blocked.ttl")] } }); + expect(screen.queryByRole("listitem")).not.toBeInTheDocument(); + + rerender(); + expect(dropzone).toHaveAttribute("data-state", "idle"); + fireEvent.dragEnter(dropzone); + fireEvent.dragLeave(dropzone); + expect(dropzone).toHaveAttribute("data-state", "idle"); + }); + + it("clears the drag state after dropping non-file content", () => { + renderFileUpload(); + const dropzone = document.querySelector('[data-dropzone-for="Files"]')!; + + fireEvent.dragEnter(dropzone); + fireEvent.drop(dropzone, { dataTransfer: { files: [], types: ["text/plain"] } }); + + expect(dropzone).toHaveAttribute("data-state", "idle"); + expect(dropzone.className).not.toContain("--dragging"); + expect(screen.queryByRole("listitem")).not.toBeInTheDocument(); + fireEvent.dragEnter(dropzone); + fireEvent.dragLeave(dropzone); + expect(dropzone).toHaveAttribute("data-state", "idle"); + }); + it.each([ ["file type", { acceptedFileTypes: [".ttl"] }, new File(["data"], "invalid.txt")], ["file size", { maxFileSize: 3 }, new File(["too large"], "large.ttl")], diff --git a/src/components/FileUpload/FileUpload.tsx b/src/components/FileUpload/FileUpload.tsx index 9fd650a1..2c67fc97 100644 --- a/src/components/FileUpload/FileUpload.tsx +++ b/src/components/FileUpload/FileUpload.tsx @@ -1,20 +1,16 @@ import React from "react"; +import classNames from "classnames"; import { CLASSPREFIX as eccgui } from "../../configuration/constants"; import Button from "../Button/Button"; import Icon from "../Icon/Icon"; import ProgressBar from "../ProgressBar/ProgressBar"; -import { - FileUploadBaseProps, - FileUploadHandle, - FileUploadLabels, - FileUploadParsedProps, - FileUploadResponseMetadata, - FileUploadTextProps, -} from "./types"; -import { UploadController } from "./UploadController"; +import { FileUploadHandle, FileUploadLabels, FileUploadParsedProps, FileUploadTextProps } from "./types"; +import { UploadRow } from "./UploadController"; import { UppyContextProvider, useDropzone, useFileInput } from "./uppyHeadless"; +import { useRemovalFocus } from "./useRemovalFocus"; +import { FileUploadControllerProps, useUploadController } from "./useUploadController"; interface FileSelectionProps { buttonRef: React.Ref; @@ -25,6 +21,7 @@ interface FileSelectionProps { const FileSelection = ({ buttonRef, buttonDescriptionIds, disabled, labels }: FileSelectionProps) => { const [dragging, setDragging] = React.useState(false); + // Enter/leave events also fire for nested content; count them to avoid flickering between children. const dragEntryCount = React.useRef(0); const handleDragEnter = React.useCallback(() => { dragEntryCount.current += 1; @@ -36,15 +33,18 @@ const FileSelection = ({ buttonRef, buttonDescriptionIds, disabled, labels }: Fi setDragging(false); } }, []); - const handleDrop = React.useCallback(() => { + const resetDragState = React.useCallback(() => { dragEntryCount.current = 0; setDragging(false); }, []); + React.useEffect(() => { + // Disabled handlers no longer track drag exits, so discard any drag already in progress. + if (disabled) resetDragState(); + }, [disabled, resetDragState]); const { getRootProps } = useDropzone({ noClick: true, onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, - onDrop: handleDrop, }); const { getButtonProps, getInputProps } = useFileInput(); const dropzoneProps = getRootProps(); @@ -54,20 +54,25 @@ const FileSelection = ({ buttonRef, buttonDescriptionIds, disabled, labels }: Fi event.preventDefault(); event.stopPropagation(); }; + const handleDrop = (event: React.DragEvent) => { + // Uppy skips its onDrop callback for non-file drops, but those must also clear the highlight. + resetDragState(); + if (disabled) preventDisabledDrop(event); + else dropzoneProps.onDrop(event); + }; return (
); -type InternalProps = FileUploadBaseProps & { - parseResponse?: (metadata: FileUploadResponseMetadata) => unknown; -}; +interface FileUploadRowProps { + row: UploadRow; + widgetId: string; + labels: FileUploadLabels; + disabled: boolean; + onCancel: (fileId: string) => void; + onRetry: (fileId: string) => void; + onRemove: (fileId: string) => void; + removeButtonRef: React.Ref; +} -const readResponseText = (metadata: FileUploadResponseMetadata): string => metadata.responseText; +const FileUploadRow = ({ + row, + widgetId, + labels, + disabled, + onCancel, + onRetry, + onRemove, + removeButtonRef, +}: FileUploadRowProps) => { + const { file, status, progress } = row; + const nameId = `${widgetId}-${file.id}-name`; + const isCancelled = status === "cancelled"; + const canCancel = status === "uploading" || status === "queued" || status === "pendingApproval"; + const canRetry = status === "error" || isCancelled; + const progressIntent = status === "complete" ? "success" : status === "error" ? "danger" : undefined; -function FileUploadInner(props: InternalProps, ref: React.ForwardedRef>) { + return ( +
+
+ {file.name} + + {isCancelled ? ( + + + ) : ( + + )} + {canCancel &&
+ +
+ ); +}; + +function FileUploadInner(props: FileUploadControllerProps, ref: React.ForwardedRef>) { const generatedId = React.useId().replace(/[^a-zA-Z0-9_-]/g, ""); const widgetId = props.id ?? `file-upload-${generatedId}`; - const current = React.useRef(props); - current.current = props; - const [controller] = React.useState( - () => - new UploadController(widgetId, () => ({ - ...current.current, - parseResponse: current.current.parseResponse ?? readResponseText, - })), - ); - const { files, state, error, announcement } = React.useSyncExternalStore( - controller.subscribe, - controller.getSnapshot, - controller.getSnapshot, - ); - const initialized = React.useRef(false); - const groupRef = React.useRef(null); - const browseRef = React.useRef(null); - const removeRefs = React.useRef(new Map()); - const pendingFocus = React.useRef(); - React.useEffect(() => { - if (!pendingFocus.current) return; - const next = pendingFocus.current.map((id) => removeRefs.current.get(id)).find(Boolean); - pendingFocus.current = undefined; - const browse = browseRef.current; - (next ?? (browse && !browse.disabled ? browse : groupRef.current))?.focus(); - }, [files]); + const { controller, snapshot } = useUploadController(widgetId, props); + const { files, state, error, announcement } = snapshot; + const { groupRef, browseRef, registerRemoveButton, removeRow } = useRemovalFocus(files, controller.remove); React.useImperativeHandle(ref, () => controller, [controller]); - React.useEffect(() => controller.attach(), [controller]); - React.useEffect(() => { - controller.configure(); - }, [ - controller, - props.acceptedFileTypes, - props.maxFileSize, - props.maxNumberOfFiles, - props.method, - props.concurrency, - props.disabled, - props.autoUpload, - ]); - React.useEffect(() => { - if (!initialized.current) { - initialized.current = true; - if (props.initialFiles?.length) controller.addInitialFiles(props.initialFiles); - } - }, [controller, props.initialFiles]); const { name, hideName = false, labels, instructions, disabled = false, selectionDisabled = false } = props; const labelId = `${widgetId}-label`; @@ -177,13 +208,7 @@ function FileUploadInner(props: InternalProps, ref: React.ForwardedRef { - const removableIds = files.filter((row) => row.status === "cancelled").map((row) => row.file.id); - const index = removableIds.indexOf(fileId); - pendingFocus.current = removableIds.slice(index + 1).concat(removableIds.slice(0, index).reverse()); - controller.remove(fileId); - }; - const active = state.uploading > 0 || state.queued > 0 || state.pendingApproval > 0; + const hasPendingWork = state.uploading > 0 || state.queued > 0 || state.pendingApproval > 0; return (
{labels.overallUploadProgress} - {active && ( + {hasPendingWork && (
- -
+ row={row} + widgetId={widgetId} + labels={labels} + disabled={disabled} + onCancel={controller.cancelFile} + onRetry={controller.retry} + onRemove={removeRow} + removeButtonRef={(element) => registerRemoveButton(row.file.id, element)} + /> ))} @@ -334,5 +307,11 @@ interface FileUploadComponent { } // React.forwardRef cannot preserve an overloaded generic call signature. +/** + * Select and upload files through a native picker or dropzone, with localized progress, + * errors and retry actions. Approved files upload automatically unless autoUpload is false. + * The forwarded ref exposes upload, cancel, remove and reset for application-controlled flows. + * Responses are strings by default; parseResponse determines the response type when provided. + */ export const FileUpload = React.forwardRef(FileUploadInner) as FileUploadComponent; export default FileUpload; diff --git a/src/components/FileUpload/README.md b/src/components/FileUpload/README.md index 12cecc7c..4f1faf2e 100644 --- a/src/components/FileUpload/README.md +++ b/src/components/FileUpload/README.md @@ -219,3 +219,11 @@ structured detail. Diagnostic error text is not a localization contract. Other e and reset do not re-add these files. - `disabled` prevents selection and future request starts, but cancellation/removal remain available. `selectionDisabled` blocks only picker/drop interaction. + +## Implementation + +`UploadController` owns upload state and scheduling. `useUploadController` connects its lifecycle +and snapshots to React. `FileUpload.tsx` composes selection, progress and individual file rows. +`useRemovalFocus` restores focus after a visible Remove action: the next Remove button, the closest +previous Remove button, enabled Browse, or the widget itself. It waits for React to update the DOM +before choosing among the remaining controls. diff --git a/src/components/FileUpload/useRemovalFocus.ts b/src/components/FileUpload/useRemovalFocus.ts new file mode 100644 index 00000000..07b86cc7 --- /dev/null +++ b/src/components/FileUpload/useRemovalFocus.ts @@ -0,0 +1,47 @@ +import React from "react"; + +import { UploadRow } from "./UploadController"; + +/** + * Keep focus within the uploader when a visible Remove action deletes its own button. + * After React updates the DOM, prefer the next Remove button, then the closest previous one, + * then enabled Browse, and finally the widget itself. + * + * @param files Current rows in display order; only cancelled rows have Remove buttons. + * @param removeFile Removes the selected row from the upload controller. + * @returns Refs for the group and Browse button, a Remove-button ref registry, and removeRow + * for visible Remove actions. Attach groupRef to an element with tabIndex={-1} for the final fallback. + */ +export function useRemovalFocus(files: readonly UploadRow[], removeFile: (fileId: string) => void) { + const groupRef = React.useRef(null); + const browseRef = React.useRef(null); + const removeRefs = React.useRef(new Map()); + const pendingFocusIds = React.useRef(); + + React.useEffect(() => { + if (!pendingFocusIds.current) return; + // Wait for the updated DOM refs: focus the next Remove, then the closest previous Remove, + // then Browse. The group remains focusable when selection is disabled. + const nextRemoveButton = pendingFocusIds.current.map((id) => removeRefs.current.get(id)).find(Boolean); + pendingFocusIds.current = undefined; + const browseButton = browseRef.current; + const fallbackFocusTarget = browseButton && !browseButton.disabled ? browseButton : groupRef.current; + (nextRemoveButton ?? fallbackFocusTarget)?.focus(); + }, [files]); + + const registerRemoveButton = (fileId: string, element: HTMLButtonElement | HTMLAnchorElement | null) => { + if (element) removeRefs.current.set(fileId, element); + else removeRefs.current.delete(fileId); + }; + + const removeRow = (fileId: string) => { + const removableIds = files.filter((row) => row.status === "cancelled").map((row) => row.file.id); + const index = removableIds.indexOf(fileId); + const followingRemovableIds = removableIds.slice(index + 1); + const precedingRemovableIds = removableIds.slice(0, index).reverse(); + pendingFocusIds.current = [...followingRemovableIds, ...precedingRemovableIds]; + removeFile(fileId); + }; + + return { groupRef, browseRef, registerRemoveButton, removeRow }; +} diff --git a/src/components/FileUpload/useUploadController.ts b/src/components/FileUpload/useUploadController.ts new file mode 100644 index 00000000..5b86d68f --- /dev/null +++ b/src/components/FileUpload/useUploadController.ts @@ -0,0 +1,60 @@ +import React from "react"; + +import { FileUploadBaseProps, FileUploadResponseMetadata } from "./types"; +import { UploadController } from "./UploadController"; + +/** Internal props shared by uploads with plain-text responses and uploads with a custom parser. */ +export type FileUploadControllerProps = FileUploadBaseProps & { + parseResponse?: (metadata: FileUploadResponseMetadata) => unknown; +}; + +const readResponseText = (metadata: FileUploadResponseMetadata): string => metadata.responseText; + +/** + * Connect one upload controller to the component's lifetime and subscribe to its view snapshot. + * The controller keeps its identity while reading current props, applies changed configuration, + * and releases its resources on unmount. Initial files are consumed only once per mount. + * + * @param widgetId Identifier used when creating the controller; keep it stable for this mount. + * @param props Current upload configuration and callbacks; an omitted parser returns response text. + * @returns The stable controller for actions and the current snapshot for rendering. + */ +export function useUploadController( + widgetId: string, + props: FileUploadControllerProps, +): { controller: UploadController; snapshot: ReturnType } { + // Keep the controller across renders while letting ongoing uploads read current callbacks and options. + const latestProps = React.useRef(props); + latestProps.current = props; + const [controller] = React.useState( + () => + new UploadController(widgetId, () => ({ + ...latestProps.current, + parseResponse: latestProps.current.parseResponse ?? readResponseText, + })), + ); + const snapshot = React.useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot); + const initialFilesAdded = React.useRef(false); + + React.useEffect(() => controller.attach(), [controller]); + React.useEffect(() => { + controller.configure(); + }, [ + controller, + props.acceptedFileTypes, + props.maxFileSize, + props.maxNumberOfFiles, + props.method, + props.concurrency, + props.disabled, + props.autoUpload, + ]); + React.useEffect(() => { + // Initial files belong to this mount; neither StrictMode effect replay nor reset should add them again. + if (initialFilesAdded.current) return; + initialFilesAdded.current = true; + if (props.initialFiles?.length) controller.addInitialFiles(props.initialFiles); + }, [controller, props.initialFiles]); + + return { controller, snapshot }; +} From 76a116787974ef2f468f6660fc02d3c83d7332b2 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Fri, 18 Sep 2026 15:23:51 +0200 Subject: [PATCH 11/12] Improve FileUpload accessibility and package compatibility checks - Add Storybook browser tests for FileUpload accessibility, keyboard interaction, and focus handling, with CI coverage - Resolve Uppy 5 through public package types and remove the legacy compatibility adapter - Update module resolution and CommonJS/Sass compiler settings - Improve FileUpload contrast, reset drag highlighting on drops, and guard missing file data and response bodies - Simplify CodeMirror mode selection and test supported modes - Export package declarations for modern TypeScript consumers - Add packed-package consumer tests for bundler, Node16, and NodeNext resolution, plus ESM/CommonJS runtime checks - Run consumer checks in PR and release workflows, with optional dependency declaration validation - Document test commands and update changelog and tooling --- .github/workflows/push-tagged-release.yml | 2 + .github/workflows/test-code.yml | 2 + .github/workflows/test-storybook.yml | 33 ++++ .gitignore | 1 + .storybook/tests/file-upload.mts | 181 ++++++++++++++++++ .storybook/tests/runner.test.mts | 16 ++ .typescript/tsbuild-cjs.json | 3 +- .typescript/tscheck-package-consumer.json | 17 ++ .typescript/tscheck-storybook.json | 18 ++ CHANGELOG.md | 3 + README.md | 52 ++++- eslint.config.mjs | 2 +- package.json | 21 +- scripts/run-storybook-tests.mts | 59 ++++++ scripts/test-package-consumer.mts | 58 ++++++ scripts/test-storybook.mts | 73 +++++++ .../FileUpload/FileUpload.stories.tsx | 29 ++- src/components/FileUpload/FileUpload.tsx | 25 +-- src/components/FileUpload/UploadController.ts | 13 +- src/components/FileUpload/fileupload.scss | 3 +- src/components/FileUpload/uppyHeadless.ts | 20 -- .../useCodemirrorModeExtension.hooks.test.ts | 15 ++ .../hooks/useCodemirrorModeExtension.hooks.ts | 28 +-- tests/package-consumer/commonjs.cts | 7 + tests/package-consumer/consumer.tsx | 33 ++++ tests/package-consumer/esm.mts | 7 + tests/package-consumer/package.json | 4 + tests/package-consumer/tsconfig.bundler.json | 8 + tests/package-consumer/tsconfig.json | 13 ++ tests/package-consumer/tsconfig.node16.json | 7 + tests/package-consumer/tsconfig.nodenext.json | 7 + tsconfig.json | 2 +- 32 files changed, 692 insertions(+), 70 deletions(-) create mode 100644 .github/workflows/test-storybook.yml create mode 100644 .storybook/tests/file-upload.mts create mode 100644 .storybook/tests/runner.test.mts create mode 100644 .typescript/tscheck-package-consumer.json create mode 100644 .typescript/tscheck-storybook.json create mode 100644 scripts/run-storybook-tests.mts create mode 100644 scripts/test-package-consumer.mts create mode 100644 scripts/test-storybook.mts delete mode 100644 src/components/FileUpload/uppyHeadless.ts create mode 100644 src/extensions/codemirror/hooks/useCodemirrorModeExtension.hooks.test.ts create mode 100644 tests/package-consumer/commonjs.cts create mode 100644 tests/package-consumer/consumer.tsx create mode 100644 tests/package-consumer/esm.mts create mode 100644 tests/package-consumer/package.json create mode 100644 tests/package-consumer/tsconfig.bundler.json create mode 100644 tests/package-consumer/tsconfig.json create mode 100644 tests/package-consumer/tsconfig.node16.json create mode 100644 tests/package-consumer/tsconfig.nodenext.json diff --git a/.github/workflows/push-tagged-release.yml b/.github/workflows/push-tagged-release.yml index 8ab76f4e..f858e948 100644 --- a/.github/workflows/push-tagged-release.yml +++ b/.github/workflows/push-tagged-release.yml @@ -54,6 +54,8 @@ jobs: run: yarn install - name: Create dist JS run: yarn build:all + - name: Check package consumers + run: yarn test:package:consumer - name: Create jest results # only use for final releases because it is necessary only in addition to storybook if: ${{ inputs.onlyNpmPush != true && inputs.sectionChangelog != 'Unreleased' }} diff --git a/.github/workflows/test-code.yml b/.github/workflows/test-code.yml index 467b677d..efd36c53 100644 --- a/.github/workflows/test-code.yml +++ b/.github/workflows/test-code.yml @@ -6,12 +6,14 @@ on: - ".github/workflows/test-code.yml" - ".typescript/**" - "scripts/**" + - "tests/package-consumer/**" - "src/**.js" - "src/**.ts" - "src/**.tsx" - "index.ts" - "**.scss" - package.json + - tsconfig.json - yarn.lock jobs: diff --git a/.github/workflows/test-storybook.yml b/.github/workflows/test-storybook.yml new file mode 100644 index 00000000..1c816978 --- /dev/null +++ b/.github/workflows/test-storybook.yml @@ -0,0 +1,33 @@ +name: "Test: Storybook browser accessibility" + +on: + pull_request: + paths: + - ".github/workflows/test-storybook.yml" + - ".storybook/**" + - ".typescript/**" + - "src/**" + - "scripts/**" + - "package.json" + - "yarn.lock" + +jobs: + browser-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "24.11.1" + - run: yarn install --frozen-lockfile + - run: yarn test:storybook:types + - run: yarn test:storybook:runner + - run: yarn playwright install --with-deps chromium + - run: yarn build-storybook --output-dir .local/storybook + - run: yarn test:storybook:ci --json --outputFile .local/storybook-results.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: storybook-browser-results + path: .local/storybook-results.json diff --git a/.gitignore b/.gitignore index ba70b2d0..be339e5c 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ storybook-static # ignore Typescript assets *.tsbuildinfo +.local/ diff --git a/.storybook/tests/file-upload.mts b/.storybook/tests/file-upload.mts new file mode 100644 index 00000000..67345a94 --- /dev/null +++ b/.storybook/tests/file-upload.mts @@ -0,0 +1,181 @@ +import { Buffer } from "node:buffer"; +import type { Locator, Page } from "playwright"; +import { expect } from "playwright/test"; + +interface FileUploadScenario { + storyId: `forms-fileupload--${string}`; + description: string; + run: (page: Page) => Promise; +} + +function uploadGroup(page: Page): Locator { + return page.getByRole("group", { name: "Upload graph file", exact: true }); +} + +function browseButton(page: Page): Locator { + return uploadGroup(page).getByRole("button", { name: /browse files$/i }); +} + +function fileRow(page: Page, fileName: string): Locator { + return uploadGroup(page) + .getByRole("listitem") + .filter({ has: page.getByText(fileName, { exact: true }) }); +} + +async function testKeyboardSelection(page: Page, key: "Enter" | "Space"): Promise { + // Begin outside the controls so Tab, rather than programmatic focus, reaches Browse. + await uploadGroup(page).focus(); + await page.keyboard.press("Tab"); + await expect(browseButton(page)).toBeFocused(); + + let chooserCount = 0; + const countChooser = () => { + chooserCount += 1; + }; + page.on("filechooser", countChooser); + try { + const chooserPromise = page.waitForEvent("filechooser"); + await page.keyboard.press(key); + const chooser = await chooserPromise; + await chooser.setFiles({ name: "keyboard.ttl", mimeType: "text/turtle", buffer: Buffer.from("data") }); + + await expect(fileRow(page, "keyboard.ttl")).toBeVisible(); + expect(chooserCount, "Each key activation must open exactly one chooser").toBe(1); + await expect(page.getByRole("status")).toContainText("Selected keyboard.ttl"); + } finally { + page.off("filechooser", countChooser); + } +} + +async function testDisabledSelection(page: Page): Promise { + const browse = browseButton(page); + await expect(browse).toBeDisabled(); + + await uploadGroup(page).focus(); + await page.keyboard.press("Tab"); + + await expect(browse).not.toBeFocused(); +} + +async function testKeyboardCancellation(page: Page): Promise { + const group = uploadGroup(page); + await expect(fileRow(page, "graph.ttl")).toHaveAttribute("data-state", "uploading"); + await expect(group).toHaveAttribute("aria-busy", "true"); + + await group.getByRole("button", { name: "Cancel upload", exact: true }).focus(); + await page.keyboard.press("Space"); + + await expect(fileRow(page, "graph.ttl")).toHaveAttribute("data-state", "cancelled"); + await expect(group).not.toHaveAttribute("aria-busy"); + await expect(group.getByRole("listitem"), "Cancellation retains the file").toHaveCount(1); + await expect(group.getByRole("progressbar")).toHaveAttribute("aria-valuetext", "Upload cancelled"); + await expect(group.getByText("Upload cancelled", { exact: true })).toBeVisible(); +} + +async function waitForCancelledFiles(page: Page, fileNames: readonly string[]): Promise { + // CancelledFiles.play completes the first file and stops the remaining uploads. + await expect(fileRow(page, "first.ttl")).toHaveAttribute("data-state", "complete"); + for (const fileName of fileNames) { + await expect(fileRow(page, fileName)).toHaveAttribute("data-state", "cancelled"); + } + await expect(uploadGroup(page)).not.toHaveAttribute("aria-busy"); +} + +async function testFocusAfterRemoval(page: Page): Promise { + await waitForCancelledFiles(page, ["second.ttl", "third.ttl"]); + const removeSecond = fileRow(page, "second.ttl").getByRole("button", { name: "Remove", exact: true }); + const removeThird = fileRow(page, "third.ttl").getByRole("button", { name: "Remove", exact: true }); + + await removeSecond.focus(); + await page.keyboard.press("Enter"); + + await expect(fileRow(page, "second.ttl")).toHaveCount(0); + await expect(removeThird).toBeFocused(); + await expect(uploadGroup(page).getByRole("button", { name: "Remove", exact: true })).toHaveCount(1); + await expect(page.getByRole("status")).toContainText("second.ttl removed"); + + await page.keyboard.press("Space"); + + await expect(fileRow(page, "third.ttl")).toHaveCount(0); + await expect(browseButton(page)).toBeFocused(); + await expect(page.getByRole("status")).toContainText("third.ttl removed"); +} + +async function testContinueRetainedFiles(page: Page): Promise { + await waitForCancelledFiles(page, ["second.ttl"]); + // RemovedBeforeContinue.play also removes the third file before this interaction. + await expect(fileRow(page, "third.ttl")).toHaveCount(0); + await expect( + uploadGroup(page).getByRole("progressbar", { name: "Overall upload progress", exact: true }), + ).toHaveAttribute("aria-valuenow", "50"); + + await uploadGroup(page).getByRole("button", { name: "Continue uploads", exact: true }).focus(); + await page.keyboard.press("Enter"); + + await expect(fileRow(page, "second.ttl")).toHaveAttribute("data-state", "uploading"); + await expect(fileRow(page, "third.ttl")).toHaveCount(0); + await expect(uploadGroup(page).getByRole("listitem")).toHaveCount(2); + await expect(uploadGroup(page)).toHaveAttribute("aria-busy", "true"); +} + +async function testRetryAfterError(page: Page): Promise { + const row = fileRow(page, "retry.ttl"); + await expect(row).toHaveAttribute("data-state", "error"); + await expect(page.getByRole("alert")).toBeVisible(); + + await row.getByRole("button", { name: "Retry", exact: true }).focus(); + await page.keyboard.press("Enter"); + + await expect(row).toHaveAttribute("data-state", "complete"); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(page.getByRole("status")).toContainText("retry.ttl uploaded"); + await expect(uploadGroup(page)).not.toHaveAttribute("aria-busy"); +} + +export const fileUploadScenarios = [ + { + storyId: "forms-fileupload--keyboard-enter", + description: "Enter opens one file chooser and announces the selection", + run: (page) => testKeyboardSelection(page, "Enter"), + }, + { + storyId: "forms-fileupload--keyboard-space", + description: "Space opens one file chooser and announces the selection", + run: (page) => testKeyboardSelection(page, "Space"), + }, + { + storyId: "forms-fileupload--keyboard-in-dialog", + description: "Enter opens one file chooser inside a dialog", + run: (page) => testKeyboardSelection(page, "Enter"), + }, + { + storyId: "forms-fileupload--disabled", + description: "A disabled upload control skips Browse in the tab order", + run: testDisabledSelection, + }, + { + storyId: "forms-fileupload--selection-disabled", + description: "Disabled file selection skips Browse in the tab order", + run: testDisabledSelection, + }, + { + storyId: "forms-fileupload--uploading", + description: "Space cancels an upload and retains its file", + run: testKeyboardCancellation, + }, + { + storyId: "forms-fileupload--keyboard-removal", + description: "Removing files moves focus to the next Remove button, then Browse", + run: testFocusAfterRemoval, + }, + { + storyId: "forms-fileupload--removed-before-continue", + description: "Continue resumes retained files without restoring a removed file", + run: testContinueRetainedFiles, + }, + { + storyId: "forms-fileupload--retry-after-error", + description: "Enter retries a failed upload and announces success", + run: testRetryAfterError, + }, +] satisfies readonly FileUploadScenario[]; diff --git a/.storybook/tests/runner.test.mts b/.storybook/tests/runner.test.mts new file mode 100644 index 00000000..9ea1366f --- /dev/null +++ b/.storybook/tests/runner.test.mts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const runner = fileURLToPath(new URL("../../scripts/run-storybook-tests.mts", import.meta.url)); + +const invalidArgs = [["--url"], ["--url", ""], ["--url", "--ci"]]; +for (const args of invalidArgs) { + const result = spawnSync(process.execPath, [runner, ...args], { encoding: "utf8", timeout: 10_000 }); + + assert.ifError(result.error); + assert.equal(result.status, 1, `Expected rejection of ${JSON.stringify(args)}`); + assert.match(result.stderr, /--url requires a URL value/); +} +process.stdout.write(`Passed ${invalidArgs.length} Storybook runner argument checks.\n`); diff --git a/.typescript/tsbuild-cjs.json b/.typescript/tsbuild-cjs.json index 7b081393..942e068c 100644 --- a/.typescript/tsbuild-cjs.json +++ b/.typescript/tsbuild-cjs.json @@ -1,7 +1,8 @@ { "extends": "./../tsconfig.json", "compilerOptions": { - "module": "commonjs", + "module": "node20", + "moduleResolution": "node16", "target": "es2015", "noEmit": false, "outDir": "../dist/cjs", diff --git a/.typescript/tscheck-package-consumer.json b/.typescript/tscheck-package-consumer.json new file mode 100644 index 00000000..b28ccce5 --- /dev/null +++ b/.typescript/tscheck-package-consumer.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["../scripts/test-package-consumer.mts"] +} diff --git a/.typescript/tscheck-storybook.json b/.typescript/tscheck-storybook.json new file mode 100644 index 00000000..7dd8e4ec --- /dev/null +++ b/.typescript/tscheck-storybook.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["../.storybook/tests/**/*.mts", "../scripts/run-storybook-tests.mts", "../scripts/test-storybook.mts"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d9eba4bd..dce6a173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - `; +export const textUpload = ( + file.name.endsWith(".ttl")} + onUploadSuccess={({ body }) => { + body.toUpperCase(); + // @ts-expect-error The default response body is a string, not any. + body.missingProperty; + }} + /> +); +export const parsedUpload = ( + ({ id: 42 })} + onUploadSuccess={({ body }) => { + body.id.toFixed(); + // @ts-expect-error The parser determines the response body's type. + body.toUpperCase(); + }} + /> +); + +// @ts-expect-error Approval must return a boolean, not a message. +export const invalidApproval = "approved"} />; +// @ts-expect-error Structured response props require a parser. +export const invalidProps: FileUploadProps<{ id: number }> = uploadProps; diff --git a/tests/package-consumer/esm.mts b/tests/package-consumer/esm.mts new file mode 100644 index 00000000..044674c9 --- /dev/null +++ b/tests/package-consumer/esm.mts @@ -0,0 +1,7 @@ +import { Button, FileUpload } from "@eccenca/gui-elements"; + +export const button: typeof Button = Button; +export const upload: typeof FileUpload = FileUpload; + +// @ts-expect-error ESM imports must retain the declared component types. +export const invalidUpload: typeof FileUpload = 42; diff --git a/tests/package-consumer/package.json b/tests/package-consumer/package.json new file mode 100644 index 00000000..5a7c88e8 --- /dev/null +++ b/tests/package-consumer/package.json @@ -0,0 +1,4 @@ +{ + "name": "gui-elements-package-consumer", + "private": true +} diff --git a/tests/package-consumer/tsconfig.bundler.json b/tests/package-consumer/tsconfig.bundler.json new file mode 100644 index 00000000..8c6d70ef --- /dev/null +++ b/tests/package-consumer/tsconfig.bundler.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler" + }, + "include": ["consumer.tsx", "esm.mts"] +} diff --git a/tests/package-consumer/tsconfig.json b/tests/package-consumer/tsconfig.json new file mode 100644 index 00000000..074896e5 --- /dev/null +++ b/tests/package-consumer/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "types": ["react", "react-dom"] + }, + "include": ["consumer.tsx", "commonjs.cts", "esm.mts"] +} diff --git a/tests/package-consumer/tsconfig.node16.json b/tests/package-consumer/tsconfig.node16.json new file mode 100644 index 00000000..fc25630f --- /dev/null +++ b/tests/package-consumer/tsconfig.node16.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "Node16", + "moduleResolution": "Node16" + } +} diff --git a/tests/package-consumer/tsconfig.nodenext.json b/tests/package-consumer/tsconfig.nodenext.json new file mode 100644 index 00000000..3ae81326 --- /dev/null +++ b/tests/package-consumer/tsconfig.nodenext.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} diff --git a/tsconfig.json b/tsconfig.json index 12941d13..2262e29d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "lib": ["dom", "dom.iterable", "es5", "esnext", "es2015.collection", "es2015.iterable"], "jsx": "react", "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "allowJs": true, "resolveJsonModule": true, From 3034cd0b6de155bb7858ea126e2d50c1be6245f1 Mon Sep 17 00:00:00 2001 From: Andreas Schultz Date: Fri, 18 Sep 2026 15:38:54 +0200 Subject: [PATCH 12/12] Update yarn.lock --- yarn.lock | 1954 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 1879 insertions(+), 75 deletions(-) diff --git a/yarn.lock b/yarn.lock index 14ebdf41..ee9d456c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -82,6 +82,27 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629" integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg== +"@babel/core@^7.22.5", "@babel/core@^7.29.7", "@babel/core@^7.7.5": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" + integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/generator" "^7.29.7" + "@babel/helper-compilation-targets" "^7.29.7" + "@babel/helper-module-transforms" "^7.29.7" + "@babel/helpers" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/template" "^7.29.7" + "@babel/traverse" "^7.29.7" + "@babel/types" "^7.29.7" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/core@^7.23.0", "@babel/core@^7.23.9", "@babel/core@^7.24.4", "@babel/core@^7.26.0": version "7.26.10" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.10.tgz#5c876f83c8c4dcb233ee4b670c0606f2ac3000f9" @@ -145,26 +166,16 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/core@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7" - integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA== +"@babel/generator@^7.22.5", "@babel/generator@^7.29.7", "@babel/generator@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.8.tgz#4b0b887885422643339e09022148a4c4ebaa4979" + integrity sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg== dependencies: - "@babel/code-frame" "^7.29.7" - "@babel/generator" "^7.29.7" - "@babel/helper-compilation-targets" "^7.29.7" - "@babel/helper-module-transforms" "^7.29.7" - "@babel/helpers" "^7.29.7" - "@babel/parser" "^7.29.7" - "@babel/template" "^7.29.7" - "@babel/traverse" "^7.29.7" - "@babel/types" "^7.29.7" - "@jridgewell/remapping" "^2.3.5" - convert-source-map "^2.0.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.3" - semver "^6.3.1" + "@babel/parser" "^7.29.8" + "@babel/types" "^7.29.8" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" "@babel/generator@^7.26.10", "@babel/generator@^7.27.0": version "7.27.0" @@ -199,17 +210,6 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/generator@^7.29.7", "@babel/generator@^7.29.8": - version "7.29.8" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.8.tgz#4b0b887885422643339e09022148a4c4ebaa4979" - integrity sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg== - dependencies: - "@babel/parser" "^7.29.8" - "@babel/types" "^7.29.8" - "@jridgewell/gen-mapping" "^0.3.12" - "@jridgewell/trace-mapping" "^0.3.28" - jsesc "^3.0.2" - "@babel/helper-annotate-as-pure@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4" @@ -1509,6 +1509,15 @@ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-8.0.0.tgz#d7bd513e6843662346552c2798ab895716cf97f2" integrity sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw== +"@babel/template@^7.22.5", "@babel/template@^7.29.7": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" + integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== + dependencies: + "@babel/code-frame" "^7.29.7" + "@babel/parser" "^7.29.7" + "@babel/types" "^7.29.7" + "@babel/template@^7.26.9", "@babel/template@^7.27.0": version "7.27.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.0.tgz#b253e5406cc1df1c57dcd18f11760c2dbf40c0b4" @@ -1536,15 +1545,6 @@ "@babel/parser" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/template@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" - integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg== - dependencies: - "@babel/code-frame" "^7.29.7" - "@babel/parser" "^7.29.7" - "@babel/types" "^7.29.7" - "@babel/traverse@^7.25.9", "@babel/traverse@^7.26.10", "@babel/traverse@^7.26.5", "@babel/traverse@^7.27.0": version "7.27.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.0.tgz#11d7e644779e166c0442f9a07274d02cd91d4a70" @@ -1605,6 +1605,14 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" +"@babel/types@^7.22.5", "@babel/types@^7.29.7", "@babel/types@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== + dependencies: + "@babel/helper-string-parser" "^7.29.7" + "@babel/helper-validator-identifier" "^7.29.7" + "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.0", "@babel/types@^7.28.2": version "7.28.2" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.2.tgz#da9db0856a9a88e0a13b019881d7513588cf712b" @@ -1629,14 +1637,6 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@babel/types@^7.29.7", "@babel/types@^7.29.8": - version "7.29.8" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" - integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== - dependencies: - "@babel/helper-string-parser" "^7.29.7" - "@babel/helper-validator-identifier" "^7.29.7" - "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -2107,6 +2107,14 @@ resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-6.0.0.tgz#ef28e27c1ded1d8e5c54879a9399e7055aed1920" integrity sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA== +"@emnapi/core@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467" + integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + "@emnapi/core@1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.11.0.tgz#8a655042dbbb10d0266670c9903c34a7001c705b" @@ -2131,6 +2139,13 @@ "@emnapi/wasi-threads" "1.0.4" tslib "^2.4.0" +"@emnapi/runtime@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" + integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@1.11.0": version "1.11.0" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.11.0.tgz#ce16b3674ff7266bbf50f9668bde8a04f3014d4e" @@ -2457,6 +2472,18 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== +"@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" + integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== + +"@hapi/topo@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" + integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@humanfs/core@^0.19.1": version "0.19.1" resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" @@ -2610,6 +2637,18 @@ jest-util "30.4.1" slash "^3.0.0" +"@jest/console@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-30.5.1.tgz#54a25bf4fee09a4e4c52b5a903ca7473d89297f6" + integrity sha512-u5Ncuc+gXVUwjNMFQOnphHo2Qx2DxyC8Dvpmf4HCx4y0kvSkRRNiQYRixrvOP4V7y52JcSuNxqochCt0PpdHVg== + dependencies: + "@jest/types" "30.5.1" + "@types/node" "*" + chalk "^4.1.2" + jest-message-util "30.5.1" + jest-util "30.5.1" + slash "^3.0.0" + "@jest/core@30.4.2": version "30.4.2" resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.4.2.tgz#3d4081f894b7e2ff57d04a31842416bd07b76c32" @@ -2644,6 +2683,47 @@ pretty-format "30.4.1" slash "^3.0.0" +"@jest/core@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-30.5.1.tgz#4e2eccc3367105d7b4bacc2fb4ccdbfa34a590f1" + integrity sha512-BL9g6CJUUhIbdoAflz/Va658erSSXUIvU8XUYiWNTbZljJjZ5yaC9EJ9/dQ19rpbYV3ZOK4sBACqhPaTyhoUIA== + dependencies: + "@jest/console" "30.5.1" + "@jest/pattern" "30.5.0" + "@jest/reporters" "30.5.1" + "@jest/test-result" "30.5.1" + "@jest/transform" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + ci-info "^4.2.0" + exit-x "^0.2.2" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.11" + jest-changed-files "30.5.1" + jest-config "30.5.1" + jest-haste-map "30.5.1" + jest-message-util "30.5.1" + jest-regex-util "30.5.0" + jest-resolve "30.5.1" + jest-resolve-dependencies "30.5.1" + jest-runner "30.5.1" + jest-runtime "30.5.1" + jest-snapshot "30.5.1" + jest-util "30.5.1" + jest-validate "30.5.1" + jest-watcher "30.5.1" + pretty-format "30.5.1" + slash "^3.0.0" + +"@jest/create-cache-key-function@^30.0.0": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/create-cache-key-function/-/create-cache-key-function-30.5.1.tgz#dc7c728bb1b4a8b5cb774bdf363e4a26d89d2b0e" + integrity sha512-5qkif//qhlbt54255O/M2JtUZNlnefOzE12bi7FlApOIYO4pqrZcAwhNCfP4jOGFubez8uoLgY9czWFjYY57ow== + dependencies: + "@jest/types" "30.5.1" + "@jest/diff-sequences@30.0.1": version "30.0.1" resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" @@ -2654,6 +2734,11 @@ resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz#8be2d260e6241d6cddddd102c304fe13b4fc8e3e" integrity sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g== +"@jest/diff-sequences@30.5.0": + version "30.5.0" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.5.0.tgz#b896d470df751cc0c7d1a0c5078f80a67ce108d4" + integrity sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg== + "@jest/environment-jsdom-abstract@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz#03cf1400aea958733f3a5d20cdc983ffcedfe2b1" @@ -2677,6 +2762,16 @@ "@types/node" "*" jest-mock "30.4.1" +"@jest/environment@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-30.5.1.tgz#a28a93864515da3ad90d3a841dc32a95be462c52" + integrity sha512-eYJAkOsrpwDXPcoLNEG6lN9Zo3Cy35pxnVQD74vyIfsi/Q8wB/lZFsEjU4wrecOgT4ZviQDZaX/cFIJyMdMxTw== + dependencies: + "@jest/fake-timers" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + jest-mock "30.5.1" + "@jest/expect-utils@30.2.0": version "30.2.0" resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.2.0.tgz#4f95413d4748454fdb17404bf1141827d15e6011" @@ -2691,6 +2786,13 @@ dependencies: "@jest/get-type" "30.1.0" +"@jest/expect-utils@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.5.1.tgz#ae10e1698eff0800de971168aa783ead6a9403df" + integrity sha512-WcRWhHQdTMRDpyWKZ/6MINBmovI7zeD+bL8wFjCncRV3NQOwKy1X45IfyblfHR4k/XciIlNEdFL9QjFO+HNKOg== + dependencies: + "@jest/get-type" "30.5.0" + "@jest/expect@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.4.1.tgz#7fefc67f86c2cb2af3c86d9d41fe4a1d74862b8c" @@ -2699,6 +2801,14 @@ expect "30.4.1" jest-snapshot "30.4.1" +"@jest/expect@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-30.5.1.tgz#b42ce55ba35cc0cb2ee8aa823baec6b6fbb95d1f" + integrity sha512-uOGd40P/COyUp9xHf5jeGiGJC2/ANg+2+Tk9/xN5/LxmlY/r/gxsPrx3DTEtaRZsLAX4wgNSLEDELZ5bmVs2bA== + dependencies: + expect "30.5.1" + jest-snapshot "30.5.1" + "@jest/fake-timers@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.4.1.tgz#ad2d3412d5d005a3e45740bd4c8ee1ccae2f89e1" @@ -2711,11 +2821,28 @@ jest-mock "30.4.1" jest-util "30.4.1" +"@jest/fake-timers@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-30.5.1.tgz#6e25f439f113216590a56e6423e716a2259a1255" + integrity sha512-rEkV6YzpBXo/L9cnj2ibyuYZuyGiNWaPUsyCu3HYJbqCV4jnEiKmf7hId20z8eKjZ0JQ9jtIYKxRSmYB/OYSsA== + dependencies: + "@jest/types" "30.5.1" + "@sinonjs/fake-timers" "^15.4.0" + "@types/node" "*" + jest-message-util "30.5.1" + jest-mock "30.5.1" + jest-util "30.5.1" + "@jest/get-type@30.1.0": version "30.1.0" resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== +"@jest/get-type@30.5.0": + version "30.5.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.5.0.tgz#0fc76dd792523bf05d7715a18041c185f9128cc4" + integrity sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q== + "@jest/globals@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.4.1.tgz#6376975e137ef87926349b5e75ccf230f491e843" @@ -2726,6 +2853,16 @@ "@jest/types" "30.4.1" jest-mock "30.4.1" +"@jest/globals@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-30.5.1.tgz#2794ea50e7ef7dea2675d04467b9eb7c42f17713" + integrity sha512-VhqvQ251XIC7pk46YymI3HCeBLOdvriDw9zibekodAHEwoVvbVVgpU5H13GvmyOAzxtZCfsm1uvzqkaqJf2I+g== + dependencies: + "@jest/environment" "30.5.1" + "@jest/expect" "30.5.1" + "@jest/types" "30.5.1" + jest-mock "30.5.1" + "@jest/pattern@30.0.1": version "30.0.1" resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" @@ -2742,6 +2879,24 @@ "@types/node" "*" jest-regex-util "30.4.0" +"@jest/pattern@30.5.0": + version "30.5.0" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.5.0.tgz#9f5dd0596a684b81eba2d7c1dfdca8d09978d326" + integrity sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w== + dependencies: + "@types/node" "*" + jest-regex-util "30.5.0" + +"@jest/react-is-18@npm:react-is@^18.3.1": + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +"@jest/react-is-19@npm:react-is@^19.2.5": + version "19.3.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.3.0.tgz#716028d67e9993d23a2d353c6d08a3c1ef2bbeb4" + integrity sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA== + "@jest/reporters@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.4.1.tgz#41d42533f199e737ae352a0a0b32ff300826efe2" @@ -2771,6 +2926,35 @@ string-length "^4.0.2" v8-to-istanbul "^9.0.1" +"@jest/reporters@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-30.5.1.tgz#5e7dd01fa614fee81ff140bf48d32057bfec23b7" + integrity sha512-RbUXIfv85KxitJn4l3MpAoMilkvXe2QCO5lXxHWftywo/VdZ5vkEoHRDgphcxP6qmoIkUaN8/UZj6NhdkIFdJg== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "30.5.1" + "@jest/test-result" "30.5.1" + "@jest/transform" "30.5.1" + "@jest/types" "30.5.1" + "@jridgewell/trace-mapping" "^0.3.31" + "@types/node" "*" + chalk "^4.1.2" + collect-v8-coverage "^1.0.2" + exit-x "^0.2.2" + glob "^13.0.6" + graceful-fs "^4.2.11" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^6.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^5.0.0" + istanbul-reports "^3.1.3" + jest-message-util "30.5.1" + jest-util "30.5.1" + jest-worker "30.5.1" + slash "^3.0.0" + string-length "^4.0.2" + v8-to-istanbul "^9.0.1" + "@jest/schemas@30.0.5": version "30.0.5" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" @@ -2785,6 +2969,13 @@ dependencies: "@sinclair/typebox" "^0.34.0" +"@jest/schemas@30.5.0": + version "30.5.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.5.0.tgz#781f142de46345b903f43140b15865732abfd356" + integrity sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg== + dependencies: + "@sinclair/typebox" "^0.34.0" + "@jest/snapshot-utils@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz#0f829488b9d46b118854a16a56d509a3c6d9e064" @@ -2795,6 +2986,16 @@ graceful-fs "^4.2.11" natural-compare "^1.4.0" +"@jest/snapshot-utils@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/snapshot-utils/-/snapshot-utils-30.5.1.tgz#172720d0546ab05d5a7dc7fa3feb74c166c96310" + integrity sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw== + dependencies: + "@jest/types" "30.5.1" + chalk "^4.1.2" + graceful-fs "^4.2.11" + natural-compare "^1.4.0" + "@jest/source-map@30.0.1": version "30.0.1" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-30.0.1.tgz#305ebec50468f13e658b3d5c26f85107a5620aaa" @@ -2804,6 +3005,16 @@ callsites "^3.1.0" graceful-fs "^4.2.11" +"@jest/source-map@30.5.0": + version "30.5.0" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-30.5.0.tgz#7c3da7fafcffcc92c3605c15a7fcabba120e6f3d" + integrity sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg== + dependencies: + "@jridgewell/trace-mapping" "^0.3.31" + callsites "^3.1.0" + convert-source-map "^2.0.0" + graceful-fs "^4.2.11" + "@jest/test-result@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.4.1.tgz#e21146ebbb3e1f7f76c3c49805d9f39ae45f8de1" @@ -2814,6 +3025,16 @@ "@types/istanbul-lib-coverage" "^2.0.6" collect-v8-coverage "^1.0.2" +"@jest/test-result@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-30.5.1.tgz#084d9221f157bdbe9f16c9ba9b1a83da7831a540" + integrity sha512-A/1S6ZBdpic50E0pxLgvaB9XNPL4k7AksmG69OO2oiotxciWZwHkbOec+qbIi+uUtIIByOVlXJUl97oZUsz+Jw== + dependencies: + "@jest/console" "30.5.1" + "@jest/types" "30.5.1" + "@types/istanbul-lib-coverage" "^2.0.6" + collect-v8-coverage "^1.0.2" + "@jest/test-sequencer@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz#caf9a5e0924ed3b04957441edf9e8cef6a804391" @@ -2824,6 +3045,16 @@ jest-haste-map "30.4.1" slash "^3.0.0" +"@jest/test-sequencer@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-30.5.1.tgz#6c8d48bb957988ed38c82683e051089cc4929751" + integrity sha512-SHcPnrjdVRYJv6y6l4JUTy4Jccu7zmO6BmiOfOA34UiVBto22s19WiDC0A/2qfM7MWB/qdcoqfSIRMitpjTT4A== + dependencies: + "@jest/test-result" "30.5.1" + graceful-fs "^4.2.11" + jest-haste-map "30.5.1" + slash "^3.0.0" + "@jest/transform@30.4.1": version "30.4.1" resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.4.1.tgz#1646cddb800d38d9c4e30fecfd4a6eba0fa8acfa" @@ -2844,6 +3075,26 @@ slash "^3.0.0" write-file-atomic "^5.0.1" +"@jest/transform@30.5.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-30.5.1.tgz#563895f9cb60bc490c3addde758e78f3dd9dc321" + integrity sha512-EDnDhn0jleU9ZhpVoA4gvqw+Ev0iw/r5upNT2b79RiwiaTiYAMMhvNJP3lmocjIe5J2ZkoNr0B3K/J6O5GIm4Q== + dependencies: + "@babel/core" "^7.27.4" + "@jest/types" "30.5.1" + "@jridgewell/trace-mapping" "^0.3.31" + babel-plugin-istanbul "^8.0.0" + chalk "^4.1.2" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.11" + jest-haste-map "30.5.1" + jest-regex-util "30.5.0" + jest-util "30.5.1" + pirates "^4.0.7" + slash "^3.0.0" + write-file-atomic "^5.0.1" + "@jest/types@30.2.0": version "30.2.0" resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" @@ -2870,6 +3121,19 @@ "@types/yargs" "^17.0.33" chalk "^4.1.2" +"@jest/types@30.5.1", "@jest/types@^30.0.1": + version "30.5.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.5.1.tgz#dc08c773401c18ea0d9ca670fb4a10a22c8a4fb5" + integrity sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g== + dependencies: + "@jest/pattern" "30.5.0" + "@jest/schemas" "30.5.0" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + "@jridgewell/gen-mapping@^0.3.12": version "0.3.12" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz#2234ce26c62889f03db3d7fea43c1932ab3e927b" @@ -2942,6 +3206,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.31": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@keyv/bigmap@^1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@keyv/bigmap/-/bigmap-1.3.1.tgz#fc82fa83947e7ff68c6798d08907db842771ef2c" @@ -3323,56 +3595,111 @@ resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564" integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A== +"@parcel/watcher-android-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz#99aaa3223d43807c9340af439cad7e9b6d26ada6" + integrity sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA== + "@parcel/watcher-darwin-arm64@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e" integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA== +"@parcel/watcher-darwin-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz#024496e586b4744f09ce532bbe89fe38ef02a64e" + integrity sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw== + "@parcel/watcher-darwin-x64@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063" integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg== +"@parcel/watcher-darwin-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz#a4621df1359a93d39a332d9bab5ff09016a0608f" + integrity sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw== + "@parcel/watcher-freebsd-x64@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53" integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng== +"@parcel/watcher-freebsd-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz#7f565ed1a5b3a5e604e6a4799121518265d62a3d" + integrity sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ== + "@parcel/watcher-linux-arm-glibc@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a" integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ== +"@parcel/watcher-linux-arm-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz#ad7d3825e67b81999165da42593022045abc0889" + integrity sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg== + "@parcel/watcher-linux-arm-musl@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152" integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg== +"@parcel/watcher-linux-arm-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz#fe7d1cccb2c483215c090e938cf5cf404d2f9a8c" + integrity sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw== + "@parcel/watcher-linux-arm64-glibc@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809" integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA== +"@parcel/watcher-linux-arm64-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz#7e239dcb4646c4c79f006a7131a48238249530da" + integrity sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g== + "@parcel/watcher-linux-arm64-musl@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4" integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA== +"@parcel/watcher-linux-arm64-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz#c58b8d9c6d8d81594be00dd83aab741c1aaf7e0e" + integrity sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA== + "@parcel/watcher-linux-x64-glibc@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639" integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ== +"@parcel/watcher-linux-x64-glibc@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz#5184fa9a770478d86e56875f4ee163a0abdc8791" + integrity sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A== + "@parcel/watcher-linux-x64-musl@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2" integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg== +"@parcel/watcher-linux-x64-musl@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz#2d1c55aa7246cbc7670e2612058a8a542c9cf246" + integrity sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw== + "@parcel/watcher-win32-arm64@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e" integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q== +"@parcel/watcher-win32-arm64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz#15e09432040fee9e2213aa9c10ed589012526def" + integrity sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ== + "@parcel/watcher-win32-ia32@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d" @@ -3383,6 +3710,11 @@ resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d" integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw== +"@parcel/watcher-win32-x64@2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz#9bee199a2a4accd557b451ac2c1c793f305ae012" + integrity sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A== + "@parcel/watcher@^2.4.1": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1" @@ -3407,6 +3739,29 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" +"@parcel/watcher@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.6.0.tgz#99661f6220070b76a766aba6b7e313a087a1be4f" + integrity sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w== + dependencies: + detect-libc "^2.0.3" + is-glob "^4.0.3" + node-addon-api "^7.0.0" + picomatch "^4.0.4" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.6.0" + "@parcel/watcher-darwin-arm64" "2.6.0" + "@parcel/watcher-darwin-x64" "2.6.0" + "@parcel/watcher-freebsd-x64" "2.6.0" + "@parcel/watcher-linux-arm-glibc" "2.6.0" + "@parcel/watcher-linux-arm-musl" "2.6.0" + "@parcel/watcher-linux-arm64-glibc" "2.6.0" + "@parcel/watcher-linux-arm64-musl" "2.6.0" + "@parcel/watcher-linux-x64-glibc" "2.6.0" + "@parcel/watcher-linux-x64-musl" "2.6.0" + "@parcel/watcher-win32-arm64" "2.6.0" + "@parcel/watcher-win32-x64" "2.6.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -3429,6 +3784,23 @@ dependencies: "@types/node" "*" +"@sideway/address@^4.1.5": + version "4.1.5" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" + integrity sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" + integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sinclair/typebox@^0.34.0": version "0.34.38" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.38.tgz#2365df7c23406a4d79413a766567bfbca708b49d" @@ -3614,6 +3986,119 @@ react-docgen "^8.0.2" react-docgen-typescript "^2.2.2" +"@storybook/test-runner@0.24.5": + version "0.24.5" + resolved "https://registry.yarnpkg.com/@storybook/test-runner/-/test-runner-0.24.5.tgz#92baf0e18bd8ba888d2cbed8d39a09a86a42dd55" + integrity sha512-U8ib6parBD+wA4z5ZW/JnsR3KkEXoP+sHP8WJdDZeh0GwfTlcWjhbeR6Ij/gxNFEA1bdpBa/P8qYTOs+LKIN1g== + dependencies: + "@babel/core" "^7.22.5" + "@babel/generator" "^7.22.5" + "@babel/template" "^7.22.5" + "@babel/types" "^7.22.5" + "@jest/types" "^30.0.1" + "@swc/core" "^1.5.22" + "@swc/jest" "^0.2.38" + expect-playwright "^0.8.0" + jest "^30.0.4" + jest-circus "^30.0.4" + jest-environment-node "^30.0.4" + jest-junit "^16.0.0" + jest-process-manager "^0.4.0" + jest-runner "^30.0.4" + jest-serializer-html "^7.1.0" + jest-watch-typeahead "^3.0.1" + nyc "^15.1.0" + playwright "^1.14.0" + playwright-core ">=1.2.0" + rimraf "^3.0.2" + uuid "^8.3.2" + +"@swc/core-darwin-arm64@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz#b180d8647923c75e5aabbf4ea6e543d113aa3c79" + integrity sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA== + +"@swc/core-darwin-x64@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz#ae8286ddd5a6f1b0f1c540e3338682fed7ceb2ab" + integrity sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA== + +"@swc/core-linux-arm-gnueabihf@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz#9879d6eb91d3663dfcae557cde866642b21b7590" + integrity sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA== + +"@swc/core-linux-arm64-gnu@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz#9684df59917e4b0de8f528f04cf7f6e7526ca7b1" + integrity sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q== + +"@swc/core-linux-arm64-musl@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz#36b59ed1a46795755e6d083e6693753c5ea1e7d0" + integrity sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw== + +"@swc/core-linux-ppc64-gnu@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz#b0f648e760031e4674e0d07eb4991dfb468d21ee" + integrity sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w== + +"@swc/core-linux-s390x-gnu@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz#7757e4999f4e236a8bb05bd04367574ce926d4cc" + integrity sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg== + +"@swc/core-linux-x64-gnu@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz#a25271e8199c46e54f1042c9d98fbf38fdb4736c" + integrity sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg== + +"@swc/core-linux-x64-musl@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz#afc4e5f5e519e4e2ca75a560496fbc6b5164718d" + integrity sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ== + +"@swc/core-win32-arm64-msvc@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz#98b936a9cf2faad87c92c979161f17f22c55b742" + integrity sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ== + +"@swc/core-win32-ia32-msvc@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz#2e30f6a8f78b0c79268e6d39f0ae3b378919449c" + integrity sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q== + +"@swc/core-win32-x64-msvc@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz#a25e080ed09770e68c1dc5a2a7e973a01dc40b2f" + integrity sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ== + +"@swc/core@^1.5.22": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.16.2.tgz#6cc0c7f27938bea7208467a18ab518fa23b78367" + integrity sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw== + dependencies: + "@swc/counter" "^0.1.3" + "@swc/types" "^0.1.28" + optionalDependencies: + "@swc/core-darwin-arm64" "1.16.2" + "@swc/core-darwin-x64" "1.16.2" + "@swc/core-linux-arm-gnueabihf" "1.16.2" + "@swc/core-linux-arm64-gnu" "1.16.2" + "@swc/core-linux-arm64-musl" "1.16.2" + "@swc/core-linux-ppc64-gnu" "1.16.2" + "@swc/core-linux-s390x-gnu" "1.16.2" + "@swc/core-linux-x64-gnu" "1.16.2" + "@swc/core-linux-x64-musl" "1.16.2" + "@swc/core-win32-arm64-msvc" "1.16.2" + "@swc/core-win32-ia32-msvc" "1.16.2" + "@swc/core-win32-x64-msvc" "1.16.2" + +"@swc/counter@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + "@swc/helpers@^0.5.0": version "0.5.17" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.17.tgz#5a7be95ac0f0bf186e7e6e890e7a6f6cda6ce971" @@ -3621,6 +4106,22 @@ dependencies: tslib "^2.8.0" +"@swc/jest@^0.2.38": + version "0.2.39" + resolved "https://registry.yarnpkg.com/@swc/jest/-/jest-0.2.39.tgz#482bee0adb0726fab1487a4f902a278ec563a6b7" + integrity sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA== + dependencies: + "@jest/create-cache-key-function" "^30.0.0" + "@swc/counter" "^0.1.3" + jsonc-parser "^3.2.0" + +"@swc/types@^0.1.28": + version "0.1.28" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.28.tgz#e3cd892383fba3b8904c40518bbe1265a50753f2" + integrity sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw== + dependencies: + "@swc/counter" "^0.1.3" + "@testing-library/dom@^10.4.1": version "10.4.1" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" @@ -4071,6 +4572,13 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== +"@types/wait-on@^5.2.0": + version "5.3.4" + resolved "https://registry.yarnpkg.com/@types/wait-on/-/wait-on-5.3.4.tgz#5ee270b3e073fb01073f9f044922c6893de8c4d2" + integrity sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -4189,76 +4697,166 @@ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== +"@unrs/resolver-binding-android-arm-eabi@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz#98a9fee62c01f209747a4ab5855f1ced38a6d03a" + integrity sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w== + "@unrs/resolver-binding-android-arm64@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== +"@unrs/resolver-binding-android-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz#46b7e8a1393f907462324f1576e8883529acf066" + integrity sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ== + "@unrs/resolver-binding-darwin-arm64@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz#b4a8556f42171fb9c9f7bac8235045e82aa0cbdf" integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== +"@unrs/resolver-binding-darwin-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz#0ea07b00e2583ab004b853d4c02ec5f0745d490c" + integrity sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w== + "@unrs/resolver-binding-darwin-x64@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== +"@unrs/resolver-binding-darwin-x64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz#a2a6901ed58449b91b4438e582f6890cba956049" + integrity sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA== + "@unrs/resolver-binding-freebsd-x64@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== +"@unrs/resolver-binding-freebsd-x64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz#ebe6fe7f6706b7378ea4a48a024602e9c2f48f89" + integrity sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg== + "@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== +"@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz#e6040fedaa240124419d35b25b69c5fa15ddb499" + integrity sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A== + "@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== +"@unrs/resolver-binding-linux-arm-musleabihf@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz#d217a8fb59f659c131539326c140e7b62e3e3c6a" + integrity sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g== + "@unrs/resolver-binding-linux-arm64-gnu@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== +"@unrs/resolver-binding-linux-arm64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz#edab13c46a45783a7e01351e113825c04f352e24" + integrity sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg== + "@unrs/resolver-binding-linux-arm64-musl@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== +"@unrs/resolver-binding-linux-arm64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz#e5e195db1130f7d3b6aa2fd67b3c9fe1ea4859a0" + integrity sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA== + +"@unrs/resolver-binding-linux-loong64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz#f01d22e091bae13016f4636698d9dcbbda775c3e" + integrity sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q== + +"@unrs/resolver-binding-linux-loong64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz#7d23efcb98adf076bfbcecc27b4212c36aa6697d" + integrity sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew== + "@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== +"@unrs/resolver-binding-linux-ppc64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz#1f35f1eaa322f33cf2d96dac27f0626a93ffe2f6" + integrity sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg== + "@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== +"@unrs/resolver-binding-linux-riscv64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz#674faa696f5ce96f214873946a1e2d6ca96723dd" + integrity sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A== + "@unrs/resolver-binding-linux-riscv64-musl@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== +"@unrs/resolver-binding-linux-riscv64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz#37835fdd0b472ecdcffccd4288f19018454b138c" + integrity sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w== + "@unrs/resolver-binding-linux-s390x-gnu@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== +"@unrs/resolver-binding-linux-s390x-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz#b6edf13db4bb0accdcd1ad482a4eea0301de9224" + integrity sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw== + "@unrs/resolver-binding-linux-x64-gnu@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== +"@unrs/resolver-binding-linux-x64-gnu@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz#daddad00bf65a405202284da1eb1db8eb83b218f" + integrity sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ== + "@unrs/resolver-binding-linux-x64-musl@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== +"@unrs/resolver-binding-linux-x64-musl@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz#dfdff1e0c2bad25420b41c76a746011c3983b9bb" + integrity sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A== + +"@unrs/resolver-binding-openharmony-arm64@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz#ce07c4f5e7b42f7bfce45e7629b8659063aefefe" + integrity sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ== + "@unrs/resolver-binding-wasm32-wasi@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" @@ -4266,21 +4864,45 @@ dependencies: "@napi-rs/wasm-runtime" "^0.2.11" +"@unrs/resolver-binding-wasm32-wasi@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz#82514f0506cfaf65f17fe16095f92d450e487183" + integrity sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A== + dependencies: + "@emnapi/core" "1.10.0" + "@emnapi/runtime" "1.10.0" + "@napi-rs/wasm-runtime" "^1.1.4" + "@unrs/resolver-binding-win32-arm64-msvc@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== +"@unrs/resolver-binding-win32-arm64-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz#521427dd59a8f4740ddd1dc7c3bc6af1aa1d260d" + integrity sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g== + "@unrs/resolver-binding-win32-ia32-msvc@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== +"@unrs/resolver-binding-win32-ia32-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz#05b63286ff2da37e0ce3083b8390884385efff62" + integrity sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g== + "@unrs/resolver-binding-win32-x64-msvc@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== +"@unrs/resolver-binding-win32-x64-msvc@1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz#72da0da48d72b1e87831b9c0308931d3f4669027" + integrity sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA== + "@uppy/companion-client@^5.1.1": version "5.1.1" resolved "https://registry.yarnpkg.com/@uppy/companion-client/-/companion-client-5.1.1.tgz#5bfeda3312ba2b70e94d4fd73a31d0f7757884c0" @@ -4567,11 +5189,26 @@ acorn@^8.15.0, acorn@^8.16.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + agent-base@^7.1.0, agent-base@^7.1.2: version "7.1.4" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== +aggregate-error@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== + dependencies: + clean-stack "^2.0.0" + indent-string "^4.0.0" + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -4655,6 +5292,13 @@ ansi-regex@^6.2.2: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" @@ -4685,6 +5329,18 @@ anymatch@^3.1.3: normalize-path "^3.0.0" picomatch "^2.0.4" +append-transform@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12" + integrity sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg== + dependencies: + default-require-extensions "^3.0.0" + +archy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw== + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -4828,6 +5484,11 @@ async-function@^1.0.0: resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -4840,6 +5501,16 @@ axe-core@^4.2.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.4.tgz#5b535e381ff1e61ffdd615e5483d16186d3b46a5" integrity sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA== +axios@^1.6.1: + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.20.0.tgz#515513445aa60e71d04b6521ca6210829ccb4786" + integrity sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg== + dependencies: + follow-redirects "^1.16.0" + form-data "^4.0.6" + https-proxy-agent "^5.0.1" + proxy-from-env "^2.1.0" + babel-core@^7.0.0-bridge.0: version "7.0.0-bridge.0" resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" @@ -4858,6 +5529,19 @@ babel-jest@30.4.1, babel-jest@^30.4.1: graceful-fs "^4.2.11" slash "^3.0.0" +babel-jest@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-30.5.1.tgz#b20da241e3d525e17a1cda61eadbc2e13230baeb" + integrity sha512-ge1xUVZS91ml09YRMgRGgeKJ4YJpcOiuwteAxFBYLugQyp7cRw+hHej6Ho0vPjvLrjq60bb7JPHH9LAMA1/krA== + dependencies: + "@jest/transform" "30.5.1" + "@types/babel__core" "^7.20.5" + babel-plugin-istanbul "^8.0.0" + babel-preset-jest "30.5.0" + chalk "^4.1.2" + graceful-fs "^4.2.11" + slash "^3.0.0" + babel-loader@^10.0.0: version "10.1.1" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.1.1.tgz#ce9748e85b7071eb88006e3cfa9e6cf14eeb97c5" @@ -4876,6 +5560,17 @@ babel-plugin-istanbul@^7.0.1: istanbul-lib-instrument "^6.0.2" test-exclude "^6.0.0" +babel-plugin-istanbul@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz#8762f542153a52b77e626dd2c033b467de2f2fa2" + integrity sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-instrument "^6.0.2" + test-exclude "^7.0.1" + babel-plugin-jest-hoist@30.4.0: version "30.4.0" resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz#f7d6a6d8f435808b56b45a81dc4b61a39e36794a" @@ -4883,6 +5578,13 @@ babel-plugin-jest-hoist@30.4.0: dependencies: "@types/babel__core" "^7.20.5" +babel-plugin-jest-hoist@30.5.0: + version "30.5.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.5.0.tgz#425bf7ee24cffe47bfdcfeeca2885b87b1cf6644" + integrity sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A== + dependencies: + "@types/babel__core" "^7.20.5" + babel-plugin-polyfill-corejs2@^0.4.15: version "0.4.17" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz#198f970f1c99a856b466d1187e88ce30bd199d91" @@ -4936,6 +5638,14 @@ babel-preset-jest@30.4.0: babel-plugin-jest-hoist "30.4.0" babel-preset-current-node-syntax "^1.2.0" +babel-preset-jest@30.5.0: + version "30.5.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-30.5.0.tgz#33162eee375c0066f2b2059cd97a7840ef84c259" + integrity sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w== + dependencies: + babel-plugin-jest-hoist "30.5.0" + babel-preset-current-node-syntax "^1.2.0" + bail@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/bail/-/bail-2.0.2.tgz#d26f5cd8fe5d6f832a31517b9f7c356040ba6d5d" @@ -5035,6 +5745,16 @@ cacheable@^2.5.0: keyv "^5.6.0" qified "^0.10.1" +caching-transform@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-4.0.0.tgz#00d297a4206d71e2163c39eaffa8157ac0651f0f" + integrity sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA== + dependencies: + hasha "^5.0.0" + make-dir "^3.0.0" + package-hash "^4.0.0" + write-file-atomic "^3.0.0" + call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" @@ -5092,7 +5812,7 @@ camel-case@^4.1.2: pascal-case "^3.1.2" tslib "^2.0.3" -camelcase@^5.3.1: +camelcase@^5.0.0, camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== @@ -5142,7 +5862,16 @@ chai@^5.2.0: loupe "^3.1.0" pathval "^2.0.0" -chalk@^4.0.0, chalk@^4.1.2: +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2, chalk@~4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -5150,6 +5879,11 @@ chalk@^4.0.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.2.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + chalk@^5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" @@ -5273,6 +6007,11 @@ cjs-module-lexer@^2.1.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz#586e87d4341cb2661850ece5190232ccdebcff8b" integrity sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA== +cjs-module-lexer@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-2.2.1.tgz#ab35b03c56ade05fe170c70e67ae89f60666847c" + integrity sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q== + classcat@^5.0.3: version "5.0.5" resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" @@ -5290,6 +6029,11 @@ clean-css@^5.2.2: dependencies: source-map "~0.6.0" +clean-stack@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" + integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== + cli-cursor@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38" @@ -5313,6 +6057,15 @@ cli@~1.0.0: exit "0.1.2" glob "^7.1.1" +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -5373,6 +6126,13 @@ collect-v8-coverage@^1.0.2: resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + color-convert@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" @@ -5387,6 +6147,11 @@ color-convert@^3.1.3: dependencies: color-name "^2.0.0" +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" @@ -5438,11 +6203,23 @@ colorette@^2.0.10, colorette@^2.0.20: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + commander@^13.1.0: version "13.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-13.1.0.tgz#776167db68c78f38dcce1f9b8d7b8b9a488abf46" @@ -5458,6 +6235,11 @@ commander@^2.20.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.2.tgz#6837c3fb677ad9933d1cfba42dd14d5117d6b39e" + integrity sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow== + commander@^8.3.0: version "8.3.0" resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" @@ -5507,6 +6289,11 @@ constant-case@^3.0.4: tslib "^2.0.3" upper-case "^2.0.2" +convert-source-map@^1.7.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" @@ -5583,7 +6370,7 @@ crelt@^1.0.5: resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== -cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -5686,6 +6473,14 @@ csstype@^3.2.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== +cwd@^0.10.0: + version "0.10.0" + resolved "https://registry.yarnpkg.com/cwd/-/cwd-0.10.0.tgz#172400694057c22a13b0cf16162c7e4b7a7fe567" + integrity sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA== + dependencies: + find-pkg "^0.1.2" + fs-exists-sync "^0.1.0" + "d3-color@1 - 3": version "3.1.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" @@ -5809,6 +6604,11 @@ debug@^4.4.3: dependencies: ms "^2.1.3" +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + decimal.js@^10.5.0: version "10.6.0" resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" @@ -5859,6 +6659,13 @@ default-browser@^5.2.1: bundle-name "^4.1.0" default-browser-id "^5.0.0" +default-require-extensions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-3.0.1.tgz#bfae00feeaeada68c2ae256c62540f60b80625bd" + integrity sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw== + dependencies: + strip-bom "^4.0.0" + define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -5882,6 +6689,11 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depseek@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/depseek/-/depseek-0.4.1.tgz#f495cff2742cc119753164689595f08c2a2c484e" @@ -5919,6 +6731,13 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diffable-html@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/diffable-html/-/diffable-html-4.1.0.tgz#e7a2d1de187c4e23a59751b4e4c17483a058c696" + integrity sha512-++kyNek+YBLH8cLXS+iTj/Hiy2s5qkRJEJ8kgu/WHbFrVY2vz9xPFUT+fii2zGF0m1CaojDlQJjkfrCt7YWM1g== + dependencies: + htmlparser2 "^3.9.2" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -5975,7 +6794,7 @@ dom-serializer@^1.0.1: domhandler "^4.2.0" entities "^2.0.0" -domelementtype@1: +domelementtype@1, domelementtype@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== @@ -5992,6 +6811,13 @@ domhandler@2.3: dependencies: domelementtype "1" +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" @@ -6007,6 +6833,14 @@ domutils@1.5: dom-serializer "0" domelementtype "1" +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + domutils@^2.5.2, domutils@^2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" @@ -6116,6 +6950,11 @@ entities@1.0: resolved "https://registry.yarnpkg.com/entities/-/entities-1.0.0.tgz#b2987aa3821347fcde642b24fdfc9e4fb712bf26" integrity sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ== +entities@^1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + entities@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" @@ -6285,6 +7124,11 @@ es-toolkit@^1.43.0: resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.46.1.tgz#38ca27191a98a867fc544b81cf1477a68947fb06" integrity sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ== +es6-error@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" + integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== + "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0": version "0.28.2" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" @@ -6322,6 +7166,11 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + escape-string-regexp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" @@ -6553,11 +7402,18 @@ exit-x@^0.2.2: resolved "https://registry.yarnpkg.com/exit-x/-/exit-x-0.2.2.tgz#1f9052de3b8d99a696b10dad5bced9bdd5c3aa64" integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== -exit@0.1.2, exit@0.1.x: +exit@0.1.2, exit@0.1.x, exit@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== +expand-tilde@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449" + integrity sha512-rtmc+cjLZqnu9dSYosX9EWmSJhTwpACgJQTfj4hgg2JjOD/6SIQalZrt4a3aQeh++oNxkazcaxrhPUj6+g5G/Q== + dependencies: + os-homedir "^1.0.1" + expand-tilde@^2.0.0, expand-tilde@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502" @@ -6565,6 +7421,11 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2: dependencies: homedir-polyfill "^1.0.1" +expect-playwright@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/expect-playwright/-/expect-playwright-0.8.0.tgz#6d4ebe0bdbdd3c1693d880d97153b96a129ae4e8" + integrity sha512-+kn8561vHAY+dt+0gMqqj1oY+g5xWrsuGMk4QGxotT2WS545nVqqjs37z6hrYfIuucwqthzwJfCJUEYqixyljg== + expect@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/expect/-/expect-30.4.1.tgz#897e0390a0b6c333dbcf3a24dee3ad49553577e0" @@ -6577,6 +7438,18 @@ expect@30.4.1: jest-mock "30.4.1" jest-util "30.4.1" +expect@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.5.1.tgz#0fdbaf8a9be4c660f394d745fc17174d08b4fba6" + integrity sha512-m8YrYgvKe9+9gEnWEuKz+qCGfHqkrff7PPfyDnOFkjsfYRKqiYyOxDFMFieCGAuhtk/VNm63tvNgKZVzVy+Hvg== + dependencies: + "@jest/expect-utils" "30.5.1" + "@jest/get-type" "30.5.0" + jest-matcher-utils "30.5.1" + jest-message-util "30.5.1" + jest-mock "30.5.1" + jest-util "30.5.1" + expect@^30.0.0: version "30.2.0" resolved "https://registry.yarnpkg.com/expect/-/expect-30.2.0.tgz#d4013bed267013c14bc1199cec8aa57cee9b5869" @@ -6691,7 +7564,7 @@ find-cache-dir@^2.0.0: make-dir "^2.0.0" pkg-dir "^3.0.0" -find-cache-dir@^3.3.1: +find-cache-dir@^3.2.0, find-cache-dir@^3.3.1: version "3.3.2" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== @@ -6700,6 +7573,30 @@ find-cache-dir@^3.3.1: make-dir "^3.0.2" pkg-dir "^4.1.0" +find-file-up@^0.1.2: + version "0.1.3" + resolved "https://registry.yarnpkg.com/find-file-up/-/find-file-up-0.1.3.tgz#cf68091bcf9f300a40da411b37da5cce5a2fbea0" + integrity sha512-mBxmNbVyjg1LQIIpgO8hN+ybWBgDQK8qjht+EbrTCGmmPV/sc7RF1i9stPTD6bpvXZywBdrwRYxhSdJv867L6A== + dependencies: + fs-exists-sync "^0.1.0" + resolve-dir "^0.1.0" + +find-pkg@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/find-pkg/-/find-pkg-0.1.2.tgz#1bdc22c06e36365532e2a248046854b9788da557" + integrity sha512-0rnQWcFwZr7eO0513HahrWafsc3CTFioEB7DRiEYCUM/70QXSY8f3mCST17HXLcPvEhzH/Ty/Bxd72ZZsr/yvw== + dependencies: + find-file-up "^0.1.2" + +find-process@^1.4.4: + version "1.4.11" + resolved "https://registry.yarnpkg.com/find-process/-/find-process-1.4.11.tgz#f7246251d396b35b9ae41fff7b87137673567fcc" + integrity sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA== + dependencies: + chalk "~4.1.2" + commander "^12.1.0" + loglevel "^1.9.2" + find-up@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" @@ -6781,6 +7678,11 @@ flow-parser@0.*: resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.269.1.tgz#92067f8100d89a84433c656eb59c5b92e4036eb9" integrity sha512-2Yr0kqvT7RwaGL192nT78O5AWJeECQjl0NEzBkMsx8OJt63BvNl5yvSIbE4qZ1VDSjEkhbUgaWYdwX354bVNjw== +follow-redirects@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== + for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" @@ -6788,6 +7690,14 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" +foreground-child@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" + integrity sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^3.0.2" + foreground-child@^3.1.0: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" @@ -6814,6 +7724,17 @@ fork-ts-checker-webpack-plugin@^9.1.0: semver "^7.3.5" tapable "^2.2.1" +form-data@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.6.tgz#28e864e1b786dbebb68db1f452f9635278665827" + integrity sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.4" + mime-types "^2.1.35" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" @@ -6828,6 +7749,16 @@ framer-motion@^13.1.1: motion-utils "^13.0.0" tslib "^2.4.0" +fromentries@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.3.2.tgz#e4bca6808816bf8f93b52750f1127f5a6fd86e3a" + integrity sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg== + +fs-exists-sync@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add" + integrity sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg== + fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -6893,7 +7824,7 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-caller-file@^2.0.5: +get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -6975,7 +7906,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^10.5.0: +glob@^10.4.1, glob@^10.5.0: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -6987,7 +7918,7 @@ glob@^10.5.0: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^13.0.3: +glob@^13.0.3, glob@^13.0.6: version "13.0.6" resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== @@ -7008,6 +7939,14 @@ glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" +global-modules@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d" + integrity sha512-JeXuCbvYzYXcwE6acL9V2bAOeSIGl4dD+iwLY9iUx2VBJJ80R18HCn+JCwHM9Oegdfya3lEkGCdaRkSyc10hDA== + dependencies: + global-prefix "^0.1.4" + is-windows "^0.2.0" + global-modules@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" @@ -7024,6 +7963,16 @@ global-modules@^2.0.0: dependencies: global-prefix "^3.0.0" +global-prefix@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f" + integrity sha512-gOPiyxcD9dJGCEArAhF4Hd0BAqvAe/JzERP7tYumE4yIkmIedPUVXcJFWbV3/p/ovIIvKjkrTk+f1UVkq7vvbw== + dependencies: + homedir-polyfill "^1.0.0" + ini "^1.3.4" + is-windows "^0.2.0" + which "^1.2.12" + global-prefix@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe" @@ -7085,7 +8034,7 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -7100,6 +8049,11 @@ has-bigints@^1.0.2: resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" @@ -7136,6 +8090,14 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" +hasha@^5.0.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/hasha/-/hasha-5.2.2.tgz#a48477989b3b327aea3c04f53096d816d97522a1" + integrity sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ== + dependencies: + is-stream "^2.0.0" + type-fest "^0.8.0" + hashery@^1.4.0, hashery@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/hashery/-/hashery-1.5.1.tgz#4ba82ad54911ac617467870845d57a9fe508a400" @@ -7150,6 +8112,13 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hasown@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" + integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== + dependencies: + function-bind "^1.1.2" + hast-util-definition-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/hast-util-definition-list/-/hast-util-definition-list-2.1.0.tgz#9bdf16835c91f47d1c8ff53d732c138e128b9506" @@ -7413,7 +8382,7 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: dependencies: react-is "^16.7.0" -homedir-polyfill@^1.0.1: +homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA== @@ -7497,6 +8466,18 @@ htmlparser2@3.8.x: entities "1.0" readable-stream "1.1" +htmlparser2@^3.9.2: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + htmlparser2@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" @@ -7515,6 +8496,14 @@ http-proxy-agent@^7.0.2: agent-base "^7.1.0" debug "^4.3.4" +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" @@ -7627,7 +8616,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@~2.0.1: +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7968,6 +8957,11 @@ is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed dependencies: which-typed-array "^1.1.16" +is-typedarray@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== + is-upper-case@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/is-upper-case/-/is-upper-case-1.1.2.tgz#8d0b1fa7e7933a1e58483600ec7d9661cbaf756f" @@ -7995,7 +8989,12 @@ is-weakset@^2.0.3: call-bound "^1.0.3" get-intrinsic "^1.2.6" -is-windows@^1.0.1: +is-windows@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c" + integrity sha512-n67eJYmXbniZB7RF4I/FTjK1s6RPOCTxhYrVYLRaCt3lF0mpWZPKr3T2LSZAqyjQsxR2qMmGYXXzK0YWwcPM1Q== + +is-windows@^1.0.1, is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== @@ -8032,6 +9031,23 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== +istanbul-lib-hook@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz#8f84c9434888cc6b1d0a9d7092a76d239ebf0cc6" + integrity sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ== + dependencies: + append-transform "^2.0.0" + +istanbul-lib-instrument@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz#873c6fff897450118222774696a3f28902d77c1d" + integrity sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ== + dependencies: + "@babel/core" "^7.7.5" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + istanbul-lib-instrument@^6.0.0, istanbul-lib-instrument@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" @@ -8043,6 +9059,18 @@ istanbul-lib-instrument@^6.0.0, istanbul-lib-instrument@^6.0.2: istanbul-lib-coverage "^3.2.0" semver "^7.5.4" +istanbul-lib-processinfo@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz#366d454cd0dcb7eb6e0e419378e60072c8626169" + integrity sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg== + dependencies: + archy "^1.0.0" + cross-spawn "^7.0.3" + istanbul-lib-coverage "^3.2.0" + p-map "^3.0.0" + rimraf "^3.0.0" + uuid "^8.3.2" + istanbul-lib-report@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" @@ -8052,6 +9080,15 @@ istanbul-lib-report@^3.0.0: make-dir "^4.0.0" supports-color "^7.1.0" +istanbul-lib-source-maps@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + istanbul-lib-source-maps@^5.0.0: version "5.0.6" resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" @@ -8061,6 +9098,14 @@ istanbul-lib-source-maps@^5.0.0: debug "^4.1.1" istanbul-lib-coverage "^3.0.0" +istanbul-reports@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + istanbul-reports@^3.1.3: version "3.1.7" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" @@ -8099,6 +9144,15 @@ jest-changed-files@30.4.1: jest-util "30.4.1" p-limit "^3.1.0" +jest-changed-files@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.5.1.tgz#0f91cc77e6f834fb2e85bd277d9b313e555acd43" + integrity sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg== + dependencies: + execa "^5.1.1" + jest-util "30.5.1" + p-limit "^3.1.0" + jest-circus@30.4.2: version "30.4.2" resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.4.2.tgz#9a5b9b9c57bf51871f112ccf7a673d486c28f8e7" @@ -8125,6 +9179,32 @@ jest-circus@30.4.2: slash "^3.0.0" stack-utils "^2.0.6" +jest-circus@30.5.1, jest-circus@^30.0.4: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-30.5.1.tgz#323ff2a51a656958acbb2fe983702261eee71981" + integrity sha512-NgliezXQ6yznqR4W5Gqw++0cZSbBZlF0NknNIAE5VmSJKWOtmWJmWnBq3TSkUOVBTPrT7FGGhVdA8DLWnxo2Sw== + dependencies: + "@jest/environment" "30.5.1" + "@jest/expect" "30.5.1" + "@jest/test-result" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + chalk "^4.1.2" + co "^4.6.0" + dedent "^1.6.0" + is-generator-fn "^2.1.0" + jest-each "30.5.1" + jest-matcher-utils "30.5.1" + jest-message-util "30.5.1" + jest-runtime "30.5.1" + jest-snapshot "30.5.1" + jest-util "30.5.1" + p-limit "^3.1.0" + pretty-format "30.5.1" + pure-rand "^7.0.0" + slash "^3.0.0" + stack-utils "^2.0.6" + jest-cli@30.4.2: version "30.4.2" resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.4.2.tgz#e353ef54035c5ac97f200807c97b3d857f52bddc" @@ -8136,9 +9216,25 @@ jest-cli@30.4.2: chalk "^4.1.2" exit-x "^0.2.2" import-local "^3.2.0" - jest-config "30.4.2" - jest-util "30.4.1" - jest-validate "30.4.1" + jest-config "30.4.2" + jest-util "30.4.1" + jest-validate "30.4.1" + yargs "^17.7.2" + +jest-cli@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-30.5.1.tgz#5462c51d01b41e8f339faa88fd9139eeb0a9b34d" + integrity sha512-uwNYepWgaNBCplm42fCIUZTf/tIEHIy5AYvx7eL1BrwIgyjPt+2poouR23UO2yopIP+kV8oZFHfv+l4pg/8prQ== + dependencies: + "@jest/core" "30.5.1" + "@jest/test-result" "30.5.1" + "@jest/types" "30.5.1" + chalk "^4.1.2" + exit-x "^0.2.2" + import-local "^3.2.0" + jest-config "30.5.1" + jest-util "30.5.1" + jest-validate "30.5.1" yargs "^17.7.2" jest-config@30.4.2: @@ -8170,6 +9266,35 @@ jest-config@30.4.2: slash "^3.0.0" strip-json-comments "^3.1.1" +jest-config@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-30.5.1.tgz#fff038a74c4750c478efe3a66bde8c80051edb48" + integrity sha512-L8PKM2X/ngG8PxfLMqglKGZjylPgw84bVPUlMY9W/o76TnLqPWrmQgsaT0HrheGdRtpGE5FbmREA0Kvb+Qx5gQ== + dependencies: + "@babel/core" "^7.27.4" + "@jest/get-type" "30.5.0" + "@jest/pattern" "30.5.0" + "@jest/test-sequencer" "30.5.1" + "@jest/types" "30.5.1" + babel-jest "30.5.1" + chalk "^4.1.2" + ci-info "^4.2.0" + deepmerge "^4.3.1" + glob "^13.0.6" + graceful-fs "^4.2.11" + jest-circus "30.5.1" + jest-docblock "30.5.0" + jest-environment-node "30.5.1" + jest-regex-util "30.5.0" + jest-resolve "30.5.1" + jest-runner "30.5.1" + jest-util "30.5.1" + jest-validate "30.5.1" + parse-json "^5.2.0" + pretty-format "30.5.1" + slash "^3.0.0" + strip-json-comments "^3.1.1" + jest-diff@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.2.0.tgz#e3ec3a6ea5c5747f605c9e874f83d756cba36825" @@ -8190,6 +9315,16 @@ jest-diff@30.4.1: chalk "^4.1.2" pretty-format "30.4.1" +jest-diff@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.5.1.tgz#e03474eca7e5dc42924b15c72069b3203253768d" + integrity sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q== + dependencies: + "@jest/diff-sequences" "30.5.0" + "@jest/get-type" "30.5.0" + chalk "^4.1.2" + pretty-format "30.5.1" + jest-docblock@30.4.0: version "30.4.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.4.0.tgz#3ab779a027d1495ae21550accd4266bbe99af7a3" @@ -8197,6 +9332,13 @@ jest-docblock@30.4.0: dependencies: detect-newline "^3.1.0" +jest-docblock@30.5.0: + version "30.5.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-30.5.0.tgz#7cf9d08ba714fde0b68c9cb4bfc0be867a7dd11d" + integrity sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw== + dependencies: + detect-newline "^3.1.0" + jest-each@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.4.1.tgz#b69e66da8e2b578c6140d357f6574044c2a40537" @@ -8208,6 +9350,17 @@ jest-each@30.4.1: jest-util "30.4.1" pretty-format "30.4.1" +jest-each@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-30.5.1.tgz#6647c948db58351d4081851eae329a410288bacd" + integrity sha512-S1af0TU4v1EZ/AUlkFs/sxf/5KGsbAT9kRgdyoMX/x72y7C8ZEgETE5o1TPnbKRnrbprc5FR/moYLfdRmqEjsQ== + dependencies: + "@jest/get-type" "30.5.0" + "@jest/types" "30.5.1" + chalk "^4.1.2" + jest-util "30.5.1" + pretty-format "30.5.1" + jest-environment-jsdom@^30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz#928c81b3ea630b409fc6483cd16553b90b220bfc" @@ -8230,6 +9383,19 @@ jest-environment-node@30.4.1: jest-util "30.4.1" jest-validate "30.4.1" +jest-environment-node@30.5.1, jest-environment-node@^30.0.4: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-30.5.1.tgz#c4581f0a0b656d2b17be809066c051cee99ea8ed" + integrity sha512-LrPj3sPMjsQoOB3jrb8p/sa+XkSFNKo60TTsyc+EB2kxQJHgbwElpXnx1yX25fdFn5958FIObRtcLSHyV8VIAw== + dependencies: + "@jest/environment" "30.5.1" + "@jest/fake-timers" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + jest-mock "30.5.1" + jest-util "30.5.1" + jest-validate "30.5.1" + jest-fixed-jsdom@^0.0.11: version "0.0.11" resolved "https://registry.yarnpkg.com/jest-fixed-jsdom/-/jest-fixed-jsdom-0.0.11.tgz#67b5d5c4e9821bfb1e09a43139bfb0b9f4ec4f18" @@ -8253,6 +9419,33 @@ jest-haste-map@30.4.1: optionalDependencies: fsevents "^2.3.3" +jest-haste-map@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-30.5.1.tgz#a1dbba63564492f53783d754623cdd41bb0328b0" + integrity sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ== + dependencies: + "@jest/types" "30.5.1" + "@parcel/watcher" "^2.6.0" + "@types/node" "*" + anymatch "^3.1.3" + fb-watchman "^2.0.2" + fdir "^6.5.0" + graceful-fs "^4.2.11" + jest-regex-util "30.5.0" + jest-util "30.5.1" + jest-worker "30.5.1" + picomatch "^4.0.3" + +jest-junit@^16.0.0: + version "16.0.0" + resolved "https://registry.yarnpkg.com/jest-junit/-/jest-junit-16.0.0.tgz#d838e8c561cf9fdd7eb54f63020777eee4136785" + integrity sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ== + dependencies: + mkdirp "^1.0.4" + strip-ansi "^6.0.1" + uuid "^8.3.2" + xml "^1.0.1" + jest-leak-detector@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz#96077059a68e5871fc8f53aa90647a6a33f916cd" @@ -8261,6 +9454,14 @@ jest-leak-detector@30.4.1: "@jest/get-type" "30.1.0" pretty-format "30.4.1" +jest-leak-detector@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-30.5.1.tgz#bca4b6b08d5e3a0b46560e268d28c89b15e7b72e" + integrity sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ== + dependencies: + "@jest/get-type" "30.5.0" + pretty-format "30.5.1" + jest-matcher-utils@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz#69a0d4c271066559ec8b0d8174829adc3f23a783" @@ -8281,6 +9482,16 @@ jest-matcher-utils@30.4.1: jest-diff "30.4.1" pretty-format "30.4.1" +jest-matcher-utils@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.5.1.tgz#93e64fa4362c44d68cdc7a5590a2ccf6972083f4" + integrity sha512-aroZVqwOz/wC2y6pC+obgFWKV9viaQWQTTSB6W5H55+egtUczIfXR/rxExTv92xD/ADYwpqaYfVWx1aqwKg7FA== + dependencies: + "@jest/get-type" "30.5.0" + chalk "^4.1.2" + jest-diff "30.5.1" + pretty-format "30.5.1" + jest-message-util@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" @@ -8312,6 +9523,22 @@ jest-message-util@30.4.1: slash "^3.0.0" stack-utils "^2.0.6" +jest-message-util@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.5.1.tgz#e8d04d7b6d123f5dbfb1497432cd9c2b3d510f90" + integrity sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.5.1" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-util "30.5.1" + picomatch "^4.0.3" + pretty-format "30.5.1" + slash "^3.0.0" + stack-utils "^2.0.6" + jest-mock@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" @@ -8330,11 +9557,37 @@ jest-mock@30.4.1: "@types/node" "*" jest-util "30.4.1" +jest-mock@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.5.1.tgz#a52a7286d4bbf049bf9dabaa94616a9cc28c4b84" + integrity sha512-9fVjc3leUpGID2/by/LU4Dvdcp7PFh9LlxS3QRWK3ABm+KtvEVsG/AEGeLY3gKOZsjkBxyfwGltoAVlW7dygHg== + dependencies: + "@jest/expect-utils" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + jest-util "30.5.1" + jest-pnp-resolver@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== +jest-process-manager@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/jest-process-manager/-/jest-process-manager-0.4.0.tgz#fb05c8e09ad400fd038436004815653bb98f4e8b" + integrity sha512-80Y6snDyb0p8GG83pDxGI/kQzwVTkCxc7ep5FPe/F6JYdvRDhwr6RzRmPSP7SEwuLhxo80lBS/NqOdUIbHIfhw== + dependencies: + "@types/wait-on" "^5.2.0" + chalk "^4.1.0" + cwd "^0.10.0" + exit "^0.1.2" + find-process "^1.4.4" + prompts "^2.4.1" + signal-exit "^3.0.3" + spawnd "^5.0.0" + tree-kill "^1.2.2" + wait-on "^7.0.0" + jest-regex-util@30.0.1: version "30.0.1" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" @@ -8345,6 +9598,11 @@ jest-regex-util@30.4.0: resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.4.0.tgz#f75ccc43857633df2563a03588b5cb45c7c2941b" integrity sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg== +jest-regex-util@30.5.0, jest-regex-util@^30.0.0: + version "30.5.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.5.0.tgz#aedb1932d361d4e701ecacda6ac83acf37299505" + integrity sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A== + jest-resolve-dependencies@30.4.2: version "30.4.2" resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz#152f8a4cb2dd351cedeb5ada53c89f9683a3ad92" @@ -8353,6 +9611,14 @@ jest-resolve-dependencies@30.4.2: jest-regex-util "30.4.0" jest-snapshot "30.4.1" +jest-resolve-dependencies@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-30.5.1.tgz#2ac3052a773e70277607609ba27351025cb4bb11" + integrity sha512-JKpXGONDcTaunrNVn8KCl6qAnwl06jIkvv70lhq0Ze47lsPx9sH5HoeEUiXX6iFGnliHN/qpIbQ0l38wrVmXaw== + dependencies: + jest-regex-util "30.5.0" + jest-snapshot "30.5.1" + jest-resolve@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.4.1.tgz#b9e432892dc0e2a470eb4826ef5f120a50b3205e" @@ -8367,6 +9633,19 @@ jest-resolve@30.4.1: slash "^3.0.0" unrs-resolver "^1.7.11" +jest-resolve@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-30.5.1.tgz#929372ea827696ceb710eed4168999d0dd2152ed" + integrity sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ== + dependencies: + chalk "^4.1.2" + graceful-fs "^4.2.11" + jest-haste-map "30.5.1" + jest-util "30.5.1" + jest-validate "30.5.1" + slash "^3.0.0" + unrs-resolver "^1.12.1" + jest-runner@30.4.2: version "30.4.2" resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.4.2.tgz#15debf3cb6d817538aa97427d5a79277cdff65fe" @@ -8395,6 +9674,34 @@ jest-runner@30.4.2: p-limit "^3.1.0" source-map-support "0.5.13" +jest-runner@30.5.1, jest-runner@^30.0.4: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-30.5.1.tgz#2c2bc32ae71d7be4090bcd03b5018fc1b9733cc6" + integrity sha512-FPlQE4+mwnFxXmpPrSi836KV2ZzvK1g6/nPCT8o5BcoDUUNJQeHo1/Qdkoe/QeoL4M7OeJpbnGUhYKhC1VMdaQ== + dependencies: + "@jest/console" "30.5.1" + "@jest/environment" "30.5.1" + "@jest/source-map" "30.5.0" + "@jest/test-result" "30.5.1" + "@jest/transform" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + chalk "^4.1.2" + emittery "^0.13.1" + exit-x "^0.2.2" + graceful-fs "^4.2.11" + jest-docblock "30.5.0" + jest-environment-node "30.5.1" + jest-haste-map "30.5.1" + jest-leak-detector "30.5.1" + jest-message-util "30.5.1" + jest-resolve "30.5.1" + jest-runtime "30.5.1" + jest-util "30.5.1" + jest-watcher "30.5.1" + jest-worker "30.5.1" + p-limit "^3.1.0" + jest-runtime@30.4.2: version "30.4.2" resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.4.2.tgz#03b5955003440975b12e76518ec85d091c25b84a" @@ -8423,6 +9730,42 @@ jest-runtime@30.4.2: slash "^3.0.0" strip-bom "^4.0.0" +jest-runtime@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-30.5.1.tgz#76372008c88d124ad832e2f759ad280a92cca634" + integrity sha512-UB88+NRkK2Tw/OqV7dofYcyiUGrVZtD41k/N0xQOs9fG//57XHK7JLWG52HD1UYpUAhjK4xm6DBvFX3yWudsMA== + dependencies: + "@jest/environment" "30.5.1" + "@jest/fake-timers" "30.5.1" + "@jest/globals" "30.5.1" + "@jest/source-map" "30.5.0" + "@jest/test-result" "30.5.1" + "@jest/transform" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + chalk "^4.1.2" + cjs-module-lexer "^2.2.0" + collect-v8-coverage "^1.0.2" + es-module-lexer "^2.1.0" + glob "^13.0.6" + graceful-fs "^4.2.11" + jest-haste-map "30.5.1" + jest-message-util "30.5.1" + jest-mock "30.5.1" + jest-regex-util "30.5.0" + jest-resolve "30.5.1" + jest-snapshot "30.5.1" + jest-util "30.5.1" + slash "^3.0.0" + strip-bom "^4.0.0" + +jest-serializer-html@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/jest-serializer-html/-/jest-serializer-html-7.1.0.tgz#0cfea8a03b9b82bc420fd2cb969bd76713a87c08" + integrity sha512-xYL2qC7kmoYHJo8MYqJkzrl/Fdlx+fat4U1AqYg+kafqwcKPiMkOcjWHPKhueuNEgr+uemhGc+jqXYiwCyRyLA== + dependencies: + diffable-html "^4.1.0" + jest-snapshot@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.4.1.tgz#0380cbbaa9d53d32cf7e61af98459ac10a339842" @@ -8450,6 +9793,33 @@ jest-snapshot@30.4.1: semver "^7.7.2" synckit "^0.11.8" +jest-snapshot@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-30.5.1.tgz#37fe8f798710bc1b9f598d5f51313c0c38438eae" + integrity sha512-cNWFdSb5xuDGl8hKkAZJ3YtI/PzHpAPFV+HUXWIOG8rMhpDTLVbvAc2d2wRicoYw2wGsJGLaurJT6BLC97bLXQ== + dependencies: + "@babel/core" "^7.27.4" + "@babel/generator" "^7.27.5" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + "@babel/types" "^7.27.3" + "@jest/expect-utils" "30.5.1" + "@jest/get-type" "30.5.0" + "@jest/snapshot-utils" "30.5.1" + "@jest/transform" "30.5.1" + "@jest/types" "30.5.1" + babel-preset-current-node-syntax "^1.2.0" + chalk "^4.1.2" + expect "30.5.1" + graceful-fs "^4.2.11" + jest-diff "30.5.1" + jest-matcher-utils "30.5.1" + jest-message-util "30.5.1" + jest-util "30.5.1" + pretty-format "30.5.1" + semver "^7.7.2" + synckit "^0.11.8" + jest-util@30.2.0: version "30.2.0" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" @@ -8474,6 +9844,18 @@ jest-util@30.4.1: graceful-fs "^4.2.11" picomatch "^4.0.3" +jest-util@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.5.1.tgz#1e71a1ee24f365c34001c1f8aab6d6f52ceadcb7" + integrity sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg== + dependencies: + "@jest/types" "30.5.1" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.3" + jest-validate@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.4.1.tgz#dcc4784547bf644dca0226d3266fb1bde392c5a4" @@ -8486,6 +9868,31 @@ jest-validate@30.4.1: leven "^3.1.0" pretty-format "30.4.1" +jest-validate@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-30.5.1.tgz#0922700fae123c9e1d8403bdf62924b7e3c668de" + integrity sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg== + dependencies: + "@jest/get-type" "30.5.0" + "@jest/types" "30.5.1" + camelcase "^6.3.0" + chalk "^4.1.2" + leven "^3.1.0" + pretty-format "30.5.1" + +jest-watch-typeahead@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/jest-watch-typeahead/-/jest-watch-typeahead-3.0.1.tgz#50cb9653190228b8ebd489b37645067ee0bb7584" + integrity sha512-SFmHcvdueTswZlVhPCWfLXMazvwZlA2UZTrcE7MC3NwEVeWvEcOx6HUe+igMbnmA6qowuBSW4in8iC6J2EYsgQ== + dependencies: + ansi-escapes "^7.0.0" + chalk "^5.2.0" + jest-regex-util "^30.0.0" + jest-watcher "^30.0.0" + slash "^5.0.0" + string-length "^6.0.0" + strip-ansi "^7.0.1" + jest-watcher@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.4.1.tgz#d2a78fd27553db9206947eeda6068d76bacfd276" @@ -8500,6 +9907,20 @@ jest-watcher@30.4.1: jest-util "30.4.1" string-length "^4.0.2" +jest-watcher@30.5.1, jest-watcher@^30.0.0: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-30.5.1.tgz#70fc6bbcc26282b1bae5d53d7e46259e266b238d" + integrity sha512-+FHJ7C+S7b3ySfhA1aFmoa6TztsnwZVv84ycPRn0tVN93np2EU4b8C/KadqA3l6igdjgLBTnUotab9ER0HLG8A== + dependencies: + "@jest/test-result" "30.5.1" + "@jest/types" "30.5.1" + "@types/node" "*" + ansi-escapes "^4.3.2" + chalk "^4.1.2" + emittery "^0.13.1" + jest-util "30.5.1" + string-length "^4.0.2" + jest-worker@30.4.1: version "30.4.1" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.4.1.tgz#ac010eb6c512425748a39e2d6bf05b2c4866ca4f" @@ -8511,6 +9932,17 @@ jest-worker@30.4.1: merge-stream "^2.0.0" supports-color "^8.1.1" +jest-worker@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-30.5.1.tgz#32a4c17502addef713411ca3bf951796de83e2ba" + integrity sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA== + dependencies: + "@types/node" "*" + "@ungap/structured-clone" "^1.3.0" + jest-util "30.5.1" + merge-stream "^2.0.0" + supports-color "^8.1.1" + jest-worker@^27.4.5: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" @@ -8520,6 +9952,16 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" +jest@^30.0.4: + version "30.5.1" + resolved "https://registry.yarnpkg.com/jest/-/jest-30.5.1.tgz#db781144fcff8b4859d8dd5a0108d4c3b2173cfd" + integrity sha512-3qrR8+ZXFnn7y0H2yjWQNkGGBLBY4zTRoTMAq9zJcgwLLtlyonfsCLviIXK9xuE2KgIyM+M36AFcoK2DgFR36w== + dependencies: + "@jest/core" "30.5.1" + "@jest/types" "30.5.1" + import-local "^3.2.0" + jest-cli "30.5.1" + jest@^30.4.2: version "30.4.2" resolved "https://registry.yarnpkg.com/jest/-/jest-30.4.2.tgz#e9bdb00f4bf1126d781b0d98e23130db096bbd9a" @@ -8530,6 +9972,17 @@ jest@^30.4.2: import-local "^3.2.0" jest-cli "30.4.2" +joi@^17.11.0: + version "17.13.8" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.8.tgz#92069d521eea09fd709d8abbb86b0ef02862224f" + integrity sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw== + dependencies: + "@hapi/hoek" "^9.3.0" + "@hapi/topo" "^5.1.0" + "@sideway/address" "^4.1.5" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -8655,7 +10108,7 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonc-parser@^3.3.1: +jsonc-parser@^3.2.0, jsonc-parser@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== @@ -8698,6 +10151,11 @@ kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + known-css-properties@^0.37.0: version "0.37.0" resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.37.0.tgz#10ebe49b9dbb6638860ff8a002fb65a053f4aec5" @@ -8791,6 +10249,11 @@ lodash.escaperegexp@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" integrity sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw== +lodash.flattendeep@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" + integrity sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ== + lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" @@ -8812,6 +10275,11 @@ log-update@^6.1.0: strip-ansi "^7.1.0" wrap-ansi "^9.0.0" +loglevel@^1.9.2: + version "1.9.2" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" + integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== + longest-streak@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-3.1.0.tgz#62fa67cd958742a1574af9f39866364102d90cd4" @@ -8898,7 +10366,7 @@ make-dir@^2.0.0, make-dir@^2.1.0: pify "^4.0.1" semver "^5.6.0" -make-dir@^3.0.2: +make-dir@^3.0.0, make-dir@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -9475,7 +10943,7 @@ mime-match@^1.0.2: dependencies: wildcard "^1.1.0" -mime-types@^2.1.27, mime-types@^2.1.31: +mime-types@^2.1.27, mime-types@^2.1.31, mime-types@^2.1.35: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -9509,7 +10977,7 @@ minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5, minimatch@^3.0.2, minim dependencies: brace-expansion "^1.1.7" -minimist@^1.2.6: +minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -9534,6 +11002,11 @@ minipass@^7.1.3: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== +mkdirp@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + motion-dom@^13.1.1: version "13.1.1" resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-13.1.1.tgz#bbcb7c2e7236583f20759330d1275876131dc2eb" @@ -9592,6 +11065,11 @@ napi-postinstall@^0.3.0: resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.2.tgz#03c62080e88b311c4d7423b0f15f0c920bbcc626" integrity sha512-tWVJxJHmBWLy69PvO96TZMZDrzmw5KeiZBz3RHmiM2XZ9grBJ2WgMAFVVg25nqp3ZjTFUs2Ftw1JhscL3Teliw== +napi-postinstall@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.4.tgz#7af256d6588b5f8e952b9190965d6b019653bbb9" + integrity sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -9639,6 +11117,13 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== +node-preload@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/node-preload/-/node-preload-0.2.1.tgz#c03043bb327f417a18fee7ab7ee57b408a144301" + integrity sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ== + dependencies: + process-on-spawn "^1.0.0" + node-releases@^2.0.19: version "2.0.19" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" @@ -9705,6 +11190,39 @@ nwsapi@^2.2.16: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.21.tgz#8df7797079350adda208910d8c33fc4c2d7520c3" integrity sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA== +nyc@^15.1.0: + version "15.1.0" + resolved "https://registry.yarnpkg.com/nyc/-/nyc-15.1.0.tgz#1335dae12ddc87b6e249d5a1994ca4bdaea75f02" + integrity sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A== + dependencies: + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + caching-transform "^4.0.0" + convert-source-map "^1.7.0" + decamelize "^1.2.0" + find-cache-dir "^3.2.0" + find-up "^4.1.0" + foreground-child "^2.0.0" + get-package-type "^0.1.0" + glob "^7.1.6" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-hook "^3.0.0" + istanbul-lib-instrument "^4.0.0" + istanbul-lib-processinfo "^2.0.2" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.0.2" + make-dir "^3.0.0" + node-preload "^0.2.1" + p-map "^3.0.0" + process-on-spawn "^1.0.0" + resolve-from "^5.0.0" + rimraf "^3.0.0" + signal-exit "^3.0.2" + spawn-wrap "^2.0.0" + test-exclude "^6.0.0" + yargs "^15.0.2" + object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -9835,6 +11353,11 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +os-homedir@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ== + own-keys@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" @@ -9932,6 +11455,13 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-map@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-3.0.0.tgz#d704d9af8a2ba684e2600d9a215983d4141a979d" + integrity sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ== + dependencies: + aggregate-error "^3.0.0" + p-retry@^6.1.0: version "6.2.1" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-6.2.1.tgz#81828f8dc61c6ef5a800585491572cc9892703af" @@ -9946,6 +11476,16 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-hash@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/package-hash/-/package-hash-4.0.0.tgz#3537f654665ec3cc38827387fc904c163c54f506" + integrity sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ== + dependencies: + graceful-fs "^4.1.15" + hasha "^5.0.0" + lodash.flattendeep "^4.4.0" + release-zalgo "^1.0.0" + package-json-from-dist@^1.0.0, package-json-from-dist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" @@ -10159,6 +11699,18 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" +playwright-core@1.63.0, playwright-core@>=1.2.0: + version "1.63.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.63.0.tgz#e57665bc32846c213ac39a1e4d5bc6228e76b376" + integrity sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg== + +playwright@1.63.0, playwright@^1.14.0: + version "1.63.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.63.0.tgz#99b56f9f69b1b70c44f00bf84b2fe52348ae2511" + integrity sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg== + dependencies: + playwright-core "1.63.0" + please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -10321,6 +11873,16 @@ pretty-format@30.4.1: react-is-18 "npm:react-is@^18.3.1" react-is-19 "npm:react-is@^19.2.5" +pretty-format@30.5.1: + version "30.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.5.1.tgz#0dda910a75d12346b771977b1f328517b9e846d4" + integrity sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg== + dependencies: + "@jest/react-is-18" "npm:react-is@^18.3.1" + "@jest/react-is-19" "npm:react-is@^19.2.5" + "@jest/schemas" "30.5.0" + ansi-styles "^5.2.0" + pretty-format@^27.0.2: version "27.5.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" @@ -10335,6 +11897,13 @@ prismjs@^1.30.0: resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw== +process-on-spawn@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/process-on-spawn/-/process-on-spawn-1.1.0.tgz#9d5999ba87b3bf0a8acb05322d69f2f5aa4fb763" + integrity sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q== + dependencies: + fromentries "^1.2.0" + process@^0.11.1, process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -10347,6 +11916,14 @@ promise@^8.1.0: dependencies: asap "~2.0.6" +prompts@^2.4.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" @@ -10366,6 +11943,11 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.0.0.tgz#3508a6d6b0b8eb3ca6eb2c6623b164d2ed2ab112" integrity sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -10603,6 +12185,15 @@ readable-stream@1.1: isarray "0.0.1" string_decoder "~0.10.x" +readable-stream@^3.1.1: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readable-stream@^4.0.0: version "4.7.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91" @@ -10792,6 +12383,13 @@ relateurl@^0.2.7: resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== +release-zalgo@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/release-zalgo/-/release-zalgo-1.0.0.tgz#09700b7e5074329739330e535c5a90fb67851730" + integrity sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA== + dependencies: + es6-error "^4.0.1" + remark-definition-list@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/remark-definition-list/-/remark-definition-list-2.0.0.tgz#0047c727416ffe64ee92f961d91100fda20db87b" @@ -10864,6 +12462,11 @@ require-from-string@^2.0.2: resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + reset-css@^5.0.2: version "5.0.2" resolved "https://registry.yarnpkg.com/reset-css/-/reset-css-5.0.2.tgz#67432ee97f1dc133b87b7e1475cdf0bd19556ed8" @@ -10876,6 +12479,14 @@ resolve-cwd@^3.0.0: dependencies: resolve-from "^5.0.0" +resolve-dir@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e" + integrity sha512-QxMPqI6le2u0dCLyiGzgy92kjkkL6zO0XyvHzjdTNH3zM6e5Hz3BwG6+aEyNgiQ5Xz6PwTwgQEj3U50dByPKIA== + dependencies: + expand-tilde "^1.2.2" + global-modules "^0.2.3" + resolve-dir@^1.0.0, resolve-dir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43" @@ -10945,7 +12556,7 @@ rfdc@^1.4.1: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== -rimraf@^3.0.2: +rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== @@ -10984,6 +12595,13 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +rxjs@^7.8.1: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== + dependencies: + tslib "^2.1.0" + safe-array-concat@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" @@ -11101,7 +12719,7 @@ semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^6.0.0, semver@^6.3.1: +semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -11143,6 +12761,11 @@ serialize-javascript@^6.0.2, serialize-javascript@^7.0.5: resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.1.0.tgz#9e462c5c6dec5dbc8b55d90c52a4ad6aff985b9f" integrity sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw== +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -11250,12 +12873,17 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== -slash@^5.1.0: +slash@^5.0.0, slash@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== @@ -11321,7 +12949,7 @@ source-map-support@^0.5.16, source-map-support@~0.5.20: buffer-from "^1.0.0" source-map "^0.6.0" -source-map@^0.6.0, source-map@~0.6.0, source-map@~0.6.1: +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -11331,6 +12959,28 @@ space-separated-tokens@^2.0.0: resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== +spawn-wrap@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-2.0.0.tgz#103685b8b8f9b79771318827aa78650a610d457e" + integrity sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg== + dependencies: + foreground-child "^2.0.0" + is-windows "^1.0.2" + make-dir "^3.0.0" + rimraf "^3.0.0" + signal-exit "^3.0.2" + which "^2.0.1" + +spawnd@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/spawnd/-/spawnd-5.0.0.tgz#ea72200bdc468998e84e1c3e7b914ce85fc1c32c" + integrity sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA== + dependencies: + exit "^0.1.2" + signal-exit "^3.0.3" + tree-kill "^1.2.2" + wait-port "^0.2.9" + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -11379,6 +13029,13 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" +string-length@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-6.0.0.tgz#1c7342bbf032129b2f80003e69f889c70231d791" + integrity sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg== + dependencies: + strip-ansi "^7.1.0" + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -11482,7 +13139,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.3.0: +string_decoder@^1.1.1, string_decoder@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -11700,6 +13357,13 @@ supports-color@^10.2.2: resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-10.2.2.tgz#466c2978cc5cd0052d542a0b576461c2b802ebb4" integrity sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g== +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -11843,6 +13507,15 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +test-exclude@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.2.tgz#482392077630bc57d5630c13abe908bb910dfc65" + integrity sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^10.4.1" + minimatch "^10.2.2" + tiny-invariant@^1.3.1, tiny-invariant@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" @@ -11912,6 +13585,11 @@ tr46@^5.1.0: dependencies: punycode "^2.3.1" +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + trim-lines@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" @@ -11977,7 +13655,7 @@ tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -12004,6 +13682,11 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-fest@^0.8.0: + version "0.8.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" + integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + type-flag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/type-flag/-/type-flag-3.0.0.tgz#2caef2f20f2c71e960fe1d3b6f57bae8c8246459" @@ -12054,6 +13737,13 @@ typed-array-length@^1.0.7: possible-typed-array-names "^1.0.0" reflect.getprototypeof "^1.0.6" +typedarray-to-buffer@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" + integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== + dependencies: + is-typedarray "^1.0.0" + typescript@^5.9.3: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" @@ -12215,6 +13905,36 @@ unplugin@^2.3.5: picomatch "^4.0.3" webpack-virtual-modules "^0.6.2" +unrs-resolver@^1.12.1: + version "1.12.2" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.12.2.tgz#a6c6888396abba5adaac4cab6587df866f1d7afd" + integrity sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ== + dependencies: + napi-postinstall "^0.3.4" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.12.2" + "@unrs/resolver-binding-android-arm64" "1.12.2" + "@unrs/resolver-binding-darwin-arm64" "1.12.2" + "@unrs/resolver-binding-darwin-x64" "1.12.2" + "@unrs/resolver-binding-freebsd-x64" "1.12.2" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.12.2" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.12.2" + "@unrs/resolver-binding-linux-arm64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-arm64-musl" "1.12.2" + "@unrs/resolver-binding-linux-loong64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-loong64-musl" "1.12.2" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-riscv64-musl" "1.12.2" + "@unrs/resolver-binding-linux-s390x-gnu" "1.12.2" + "@unrs/resolver-binding-linux-x64-gnu" "1.12.2" + "@unrs/resolver-binding-linux-x64-musl" "1.12.2" + "@unrs/resolver-binding-openharmony-arm64" "1.12.2" + "@unrs/resolver-binding-wasm32-wasi" "1.12.2" + "@unrs/resolver-binding-win32-arm64-msvc" "1.12.2" + "@unrs/resolver-binding-win32-ia32-msvc" "1.12.2" + "@unrs/resolver-binding-win32-x64-msvc" "1.12.2" + unrs-resolver@^1.7.11: version "1.11.1" resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" @@ -12306,7 +14026,7 @@ use-sync-external-store@^1.3.0: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz#6dcb66ef569e02f186af6b3d575f414ce746e18f" integrity sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A== -util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -12334,6 +14054,11 @@ utila@~0.4: resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" integrity sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -12384,6 +14109,26 @@ w3c-xmlserializer@^5.0.0: dependencies: xml-name-validator "^5.0.0" +wait-on@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-7.2.0.tgz#d76b20ed3fc1e2bebc051fae5c1ff93be7892928" + integrity sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ== + dependencies: + axios "^1.6.1" + joi "^17.11.0" + lodash "^4.17.21" + minimist "^1.2.8" + rxjs "^7.8.1" + +wait-port@^0.2.9: + version "0.2.14" + resolved "https://registry.yarnpkg.com/wait-port/-/wait-port-0.2.14.tgz#6df40629be2c95aa4073ceb895abef7d872b28c6" + integrity sha512-kIzjWcr6ykl7WFbZd0TMae8xovwqcqbx6FM9l+7agOgUByhzdjfzZBPK2CPufldTOMxbUivss//Sh9MFawmPRQ== + dependencies: + chalk "^2.4.2" + commander "^3.0.2" + debug "^4.1.1" + walker@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" @@ -12579,6 +14324,11 @@ which-collection@^1.0.2: is-weakmap "^2.0.2" is-weakset "^2.0.3" +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + which-pm-runs@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.1.0.tgz#35ccf7b1a0fce87bd8b92a478c9d045785d3bf35" @@ -12610,7 +14360,7 @@ which-typed-array@^1.1.2: gopd "^1.2.0" has-tostringtag "^1.0.2" -which@^1.2.14, which@^1.3.1: +which@^1.2.12, which@^1.2.14, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== @@ -12648,6 +14398,15 @@ word-wrap@^1.2.5: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -12689,6 +14448,16 @@ write-file-atomic@^2.3.0: imurmurhash "^0.1.4" signal-exit "^3.0.2" +write-file-atomic@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" + integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== + dependencies: + imurmurhash "^0.1.4" + is-typedarray "^1.0.0" + signal-exit "^3.0.2" + typedarray-to-buffer "^3.1.5" + write-file-atomic@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7" @@ -12726,11 +14495,21 @@ xml-name-validator@^5.0.0: resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== +xml@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" + integrity sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw== + xmlchars@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -12751,6 +14530,14 @@ yaml@^2.7.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.7.1.tgz#44a247d1b88523855679ac7fa7cda6ed7e135cf6" integrity sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ== +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -12761,6 +14548,23 @@ yargs-parser@^22.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== +yargs@^15.0.2: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"