diff --git a/README.md b/README.md index 430e9634..9aebf61f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Please follow our [contribution guide](./CONTRIBUTING.md). In the development environment it is possible to use [react scan](https://react-scan.com/) to detect performance issues by analyzing the pop-up on the bottom right corner. The complete documentation is available [here](https://github.com/aidenybai/react-scan#readme). +## Features + ### Using the Catalog Contribution Feature You will need a [Thing Model Catalog](https://github.com/wot-oss/tmc) running somewhere. If you want to host it yourself, use the command-line interface to run one in the terminal using the following instructions: @@ -85,7 +87,7 @@ A local repository folder will be created inside the tm-catalog directory tmc repo remove ``` -### Send TD feature +### Using Send TD feature #### Northbound and Southbound URLs @@ -122,7 +124,7 @@ Afterwards, if the service proxies the TD, ediTDor can fetch the proxied TD cont The proxy uses the TD sent to its southbound API endpoint to communicate with a Thing. This way, you can interact with a non-HTTP Thing from your ediTDor. -### Automatically reading URL parameters +### Using URL query parameters feature The ediTDor has the functionality to automatically set the following list of variables from a URL with query parameters: @@ -147,6 +149,30 @@ Example of use: http://localhost:5173/?northbound=http://localhost:8080&southbound=http://github.com&valuePath=/value +### Using postMessage API communication feature + +The ediTDor can receive a Thing Description from another web application through the browser `postMessage` API (Documentation [here](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage)). When ediTDor is opened by another application in a new window or tab, it sends a readiness message back to the opener: + +```json +{ "type": "EDITDOR_READY" } +``` + +After that, the parent application can send a Thing Description to ediTDor with a message in the following format: + +```json +{ + "type": "LOAD_TD", + "description": "Imported TD", + "payload": "{ \"@context\": \"https://www.w3.org/ns/wot-next/td\", \"title\": \"MyThing\" }" +} +``` + +- **type** must be LOAD_TD +- **description** is a string to show in the confirmation dialog, e.g. title, id +- **payload** must be a valid JSON string containing the Thing Description + +When a valid message is received, ediTDor shows a confirmation dialog before loading the TD into the editor. If the payload is not valid JSON, an error message is shown instead. + ## Implemented Features: - JSON Editor with JSON Schema support for TD (Autocompletion, JSON Schema Validation) diff --git a/package.json b/package.json index 1d0fa76e..492c3a05 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@babel/core": "^7.26.10", "@babel/eslint-parser": "^7.27.0", "@babel/preset-react": "^7.26.3", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@types/babel__core": "^7", diff --git a/src/App.tsx b/src/App.tsx index e463efe9..8a285431 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,11 +19,20 @@ import "./App.css"; import AppFooter from "./components/App/AppFooter"; import AppHeader from "./components/App/AppHeader"; import { Container, Section, Bar } from "@column-resizer/react"; -import { RefreshCw } from "react-feather"; import { decompressSharedTd } from "./share"; import { editor } from "monaco-editor"; -import BaseButton from "./components/TDViewer/base/BaseButton"; import ErrorDialog from "./components/Dialogs/ErrorDialog"; +import DialogTemplate from "./components/Dialogs/DialogTemplate"; + +type ReadyMessage = { + type: "EDITDOR_READY"; +}; + +type LoadTdMessage = { + type: "LOAD_TD"; + description: string; + payload: string; +}; const GlobalStateWrapper = () => { return ( @@ -38,17 +47,15 @@ const BREAKPOINTS = { SMALL: 850, }; -// The useEffect hook for checking the URI was called twice somehow. -// This variable prevents the callback from being executed twice. -let checkedUrl = false; - -const App: React.FC = () => { +const App = () => { const context = useContext(ediTDorContext); const editorRef = useRef(null); - const [doShowJSON, setDoShowJSON] = useState(false); const [customBreakpointsState, setCustomBreakpointsState] = useState(0); const tdViewerRef = useRef(null); + const [pendingTd, setPendingTd] = useState(""); + const [pendingTitle, setPendingTitle] = useState(""); + const [isOpen, setIsOpen] = useState(false); const [errorDisplay, setErrorDisplay] = useState<{ state: boolean; @@ -74,8 +81,18 @@ const App: React.FC = () => { } }; - const handleToggleJSON = () => { - setDoShowJSON((prev) => !prev); + const isLoadTdMessage = (value: unknown): value is LoadTdMessage => { + if (typeof value !== "object" || value === null) { + return false; + } + + const message = value as Record; + + return ( + message.type === "LOAD_TD" && + typeof message.description === "string" && + typeof message.payload === "string" + ); }; useEffect(() => { @@ -93,24 +110,25 @@ const App: React.FC = () => { processedValue = value + "/"; } - localStorage.setItem(param, processedValue); + try { + localStorage.setItem(param, processedValue); + } catch { + showError("Failed to persist URL parameters to local storage."); + } } }); - }, [window.location.search]); + }, []); useEffect(() => { - if ( - checkedUrl || - (window.location.search.indexOf("td") <= -1 && - window.location.search.indexOf("proxyEndpoint") <= -1 && - window.location.search.indexOf("localstorage") <= -1 && - window.location.search.indexOf("southboundTdId") <= -1) - ) { + const url = new URL(window.location.href); + + const hasRelevantParam = + url.searchParams.has("td") || url.searchParams.has("localstorage"); + + if (!hasRelevantParam) { return; } - checkedUrl = true; - const url = new URL(window.location.href); const compressedTd = url.searchParams.get("td"); if (compressedTd !== null) { const td = decompressSharedTd(compressedTd); @@ -125,23 +143,23 @@ const App: React.FC = () => { } if (url.searchParams.has("localstorage")) { - let td = localStorage.getItem("td"); - if (!td) { + const storedTd = localStorage.getItem("td"); + if (!storedTd) { showError("Request to read TD from local storage failed."); return; } try { - td = JSON.parse(td); - context.updateOfflineTD(JSON.stringify(td, null, 2)); - } catch (e) { - context.updateOfflineTD(td); + const parsedTd: ThingDescription = JSON.parse(storedTd); + context.updateOfflineTD(JSON.stringify(parsedTd, null, 2)); + } catch (error) { + context.updateOfflineTD(storedTd); showError( - `Tried to JSON parse the TD from local storage, but failed: ${e}` + `Tried to JSON parse the TD from local storage, but failed: ${error}` ); } } - }, [context]); + }, []); useEffect(() => { if (!tdViewerRef.current) return; @@ -163,6 +181,52 @@ const App: React.FC = () => { return () => resizeObserver.disconnect(); }, []); + useEffect(() => { + const readyMessage: ReadyMessage = { + type: "EDITDOR_READY", + }; + + const handleMessage = (event: MessageEvent) => { + if (event.source !== window.opener) { + return; + } + + if (!isLoadTdMessage(event.data)) { + return; + } + + try { + JSON.parse(event.data.payload); + setPendingTitle(event.data.description); + setPendingTd(event.data.payload); + setIsOpen(true); + } catch { + showError("Received invalid JSON from the other application."); + } + }; + + window.addEventListener("message", handleMessage); + + if (window.opener) { + window.opener.postMessage(readyMessage, "*"); + } + + return () => { + window.removeEventListener("message", handleMessage); + }; + }, []); + + const onHandleEventRightButton = () => { + context.updateOfflineTD(pendingTd); + setPendingTd(""); + setIsOpen(false); + }; + + const onHandleEventLeftButton = () => { + setPendingTd(""); + setIsOpen(false); + }; + return (
@@ -187,15 +251,6 @@ const App: React.FC = () => {
- - - -
@@ -207,6 +262,15 @@ const App: React.FC = () => { onClose={() => setErrorDisplay({ state: false, message: "" })} errorMessage={errorDisplay.message} /> + {isOpen && ( + + )}
); }; diff --git a/src/context/editorReducers.ts b/src/context/editorReducers.ts index 511ed294..ef01e8ab 100644 --- a/src/context/editorReducers.ts +++ b/src/context/editorReducers.ts @@ -97,7 +97,7 @@ const updateOfflineTDReducer = ( try { parsedTD = JSON.parse(offlineTD); } catch (e) { - console.error((e as Error).message); + // console.error((e as Error).message); return { ...state, offlineTD: offlineTD, diff --git a/src/tests/App.integration.test.tsx b/src/tests/App.integration.test.tsx new file mode 100644 index 00000000..bbe8be5e --- /dev/null +++ b/src/tests/App.integration.test.tsx @@ -0,0 +1,609 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { + act, + render, + screen, + cleanup, + fireEvent, + waitFor, +} from "@testing-library/react"; +import { useContext } from "react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import App from "../App"; +import ediTDorContext from "../context/ediTDorContext"; +import { decompressSharedTd } from "../share"; +import { + THING_DESCRIPTION_LAMP_V_STRING, + THING_DESCRIPTION_LAMP_JSON, +} from "./constants"; + +vi.mock("@node-wot/browser-bundle", () => ({ + Core: { + Servient: vi.fn().mockImplementation(() => ({ + addClientFactory: vi.fn(), + start: vi.fn().mockResolvedValue({ + consume: vi.fn(), + }), + })), + }, + Http: { + HttpClientFactory: vi.fn(), + }, +})); + +vi.mock("../components/Editor/JsonEditor", () => ({ + default: () => { + const { offlineTD } = useContext(ediTDorContext); + + return
{offlineTD}
; + }, +})); + +vi.mock("../components/TDViewer/TDViewer", () => ({ + default: () =>
TDViewer
, +})); + +vi.mock("../share", () => ({ + decompressSharedTd: vi.fn(), +})); + +vi.mock("@column-resizer/react", () => ({ + Container: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Section: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + Bar: () =>
, +})); + +vi.mock("../components/Dialogs/ConvertTmDialog", async () => { + const React = await import("react"); + + const MockConvertTmDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + close: () => undefined, + })); + + return null; + }); + + return { default: MockConvertTmDialog }; +}); + +vi.mock("../components/Dialogs/CreateTdDialog", async () => { + const React = await import("react"); + + const MockCreateTdDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + })); + + return null; + }); + + return { default: MockCreateTdDialog }; +}); + +vi.mock("../components/Dialogs/ShareDialog", async () => { + const React = await import("react"); + + const MockShareDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + })); + + return null; + }); + + return { default: MockShareDialog }; +}); + +vi.mock("../components/Dialogs/ContributeToCatalogDialog", async () => { + const React = await import("react"); + + const MockContributeToCatalogDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + close: () => undefined, + })); + + return null; + }); + + return { default: MockContributeToCatalogDialog }; +}); + +vi.mock("../components/Dialogs/SendTDDialog", async () => { + const React = await import("react"); + + const MockSendTdDialog = React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ + openModal: () => undefined, + close: () => undefined, + })); + + return null; + }); + + return { default: MockSendTdDialog }; +}); + +const mockedDecompressSharedTd = vi.mocked(decompressSharedTd); + +beforeEach(() => { + localStorage.clear(); + window.history.replaceState({}, "", "/"); + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: null, + }); + + Object.defineProperty(window, "confirm", { + configurable: true, + writable: true, + value: vi.fn(() => true), + }); + + class ResizeObserverMock { + observe() {} + disconnect() {} + unobserve() {} + } + + vi.stubGlobal("ResizeObserver", ResizeObserverMock); + mockedDecompressSharedTd.mockReset(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + localStorage.clear(); + window.history.replaceState({}, "", "/"); +}); + +describe("App component URL bootstrapping logic", () => { + test("reads northbound, southbound and value path from url params and shows them in Settings", () => { + window.history.replaceState( + {}, + "", + "/?northbound=http://localhost:8080&southbound=http://localhost:9090&valuePath=/foo/bar" + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /settings/i })); + + const northboundInput = screen.getByLabelText(/target url northbound/i); + const southboundInput = screen.getByLabelText(/target url southbound/i); + const valuePathInput = screen.getByLabelText(/json pointer path/i); + + expect(northboundInput).toHaveValue("http://localhost:8080/"); + expect(southboundInput).toHaveValue("http://localhost:9090/"); + expect(valuePathInput).toHaveValue("/foo/bar"); + }); + test("reads northbound, southbound and value path with slashes from url params and shows them in Settings", () => { + window.history.replaceState( + {}, + "", + "/?northbound=http://localhost:8080/&southbound=http://localhost:9090/&valuePath=/foo/bar" + ); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /settings/i })); + + const northboundInput = screen.getByLabelText(/target url northbound/i); + const southboundInput = screen.getByLabelText(/target url southbound/i); + const valuePathInput = screen.getByLabelText(/json pointer path/i); + + expect(northboundInput).toHaveValue("http://localhost:8080/"); + expect(southboundInput).toHaveValue("http://localhost:9090/"); + expect(valuePathInput).toHaveValue("/foo/bar"); + }); + + test("loads a TD from the td query param into the editor state", async () => { + mockedDecompressSharedTd.mockReturnValue(THING_DESCRIPTION_LAMP_JSON); + + window.history.replaceState({}, "", "/?td=compressed-value"); + + render(); + + await waitFor(() => { + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("compressed-value"); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); + + test("loads a TD from local storage when the localstorage query param is present", async () => { + localStorage.setItem("td", THING_DESCRIPTION_LAMP_V_STRING); + + window.history.replaceState({}, "", "/?localstorage=1"); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); + test("shows an error when localstorage query param exists but td is missing", async () => { + window.history.replaceState({}, "", "/?localstorage=1"); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/request to read td from local storage failed/i) + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + test("loads invalid JSON from local storage as raw text and shows an error", async () => { + localStorage.setItem("td", "not-json"); + + window.history.replaceState({}, "", "/?localstorage=1"); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent("not-json"); + expect( + screen.getByText( + /tried to json parse the td from local storage, but failed/i + ) + ).toBeInTheDocument(); + }); + }); + test("does nothing when no relevant URL params are present", async () => { + window.history.replaceState({}, "", "/"); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect(mockedDecompressSharedTd).not.toHaveBeenCalled(); + expect( + screen.queryByText(/couldn't be reconstructed/i) + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/request to read td from local storage failed/i) + ).not.toBeInTheDocument(); + }); + test("shows an error when the td query param cannot be decompressed", async () => { + mockedDecompressSharedTd.mockReturnValue(undefined); + + window.history.replaceState({}, "", "/?td=broken-value"); + + render(); + + await waitFor(() => { + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("broken-value"); + expect( + screen.getByText( + /the lz compressed td found in the url couldn't be reconstructed/i + ) + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + test("loads local storage after td when both query params are present", async () => { + mockedDecompressSharedTd.mockReturnValue({ + title: "FromCompressedTd", + id: "urn:compressed", + }); + + localStorage.setItem("td", THING_DESCRIPTION_LAMP_V_STRING); + + window.history.replaceState({}, "", "/?td=compressed-value&localstorage=1"); + + render(); + + await waitFor(() => { + expect(mockedDecompressSharedTd).toHaveBeenCalledWith("compressed-value"); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + + expect(screen.getByTestId("offline-td")).not.toHaveTextContent( + '"title": "FromCompressedTd"' + ); + }); + test("does not read local storage when td decompression fails even if localstorage is present", async () => { + mockedDecompressSharedTd.mockReturnValue(undefined); + localStorage.setItem("td", THING_DESCRIPTION_LAMP_V_STRING); + + window.history.replaceState({}, "", "/?td=broken-value&localstorage=1"); + + render(); + + await waitFor(() => { + expect( + screen.getByText( + /the lz compressed td found in the url couldn't be reconstructed/i + ) + ).toBeInTheDocument(); + }); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + test("shows an error when persisting URL params to local storage fails", async () => { + const setItemSpy = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("storage failed"); + }); + + window.history.replaceState( + {}, + "", + "/?northbound=http://localhost:8080&southbound=http://localhost:9090&valuePath=/foo/bar" + ); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/failed to persist url parameters to local storage/i) + ).toBeInTheDocument(); + }); + + setItemSpy.mockRestore(); + }); +}); + +describe("App component receive message from other application", () => { + test("notifies the opener that editdor is ready on mount", () => { + const openerRef = { + postMessage: vi.fn(), + }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + expect(openerRef.postMessage).toHaveBeenCalledWith( + { + type: "EDITDOR_READY", + }, + "*" + ); + }); + test("loads a TD received from the other application after confirmation", async () => { + const openerRef = { + postMessage: vi.fn(), + }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: THING_DESCRIPTION_LAMP_V_STRING, + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + await act(async () => { + window.dispatchEvent(event); + }); + + expect( + await screen.findByText( + 'The Thing Description "Imported TD" was received from the other application.' + ) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /confirm/i })); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"title": "MyLampThing"' + ); + expect(screen.getByTestId("offline-td")).toHaveTextContent( + '"id": "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06"' + ); + }); + }); + test("ignores LOAD_TD messages from a source other than window.opener", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: THING_DESCRIPTION_LAMP_V_STRING, + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: {}, + }); + + await act(async () => { + window.dispatchEvent(event); + }); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect( + screen.queryByText(/received from the other application/i) + ).not.toBeInTheDocument(); + }); + test("shows an error when the other application sends invalid JSON", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Broken TD", + payload: "not-json", + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + await act(async () => { + window.dispatchEvent(event); + }); + + expect( + await screen.findByText( + /received invalid json from the other application/i + ) + ).toBeInTheDocument(); + + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + test("does not load the received TD when the user cancels", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { + type: "LOAD_TD", + description: "Imported TD", + payload: THING_DESCRIPTION_LAMP_V_STRING, + }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + await act(async () => { + window.dispatchEvent(event); + }); + + expect( + await screen.findByText( + 'The Thing Description "Imported TD" was received from the other application.' + ) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect( + screen.queryByText(/received from the other application/i) + ).not.toBeInTheDocument(); + }); + test("ignores messages that are not valid LOAD_TD payloads", async () => { + const openerRef = { postMessage: vi.fn() }; + + Object.defineProperty(window, "opener", { + configurable: true, + writable: true, + value: openerRef, + }); + + render(); + + const event = new MessageEvent("message", { + origin: "http://localhost:5175", + data: { type: "LOAD_TD", payload: THING_DESCRIPTION_LAMP_V_STRING }, + }); + + Object.defineProperty(event, "source", { + configurable: true, + value: openerRef, + }); + + await act(async () => { + window.dispatchEvent(event); + }); + + await waitFor(() => { + expect(screen.getByTestId("offline-td")).toHaveTextContent(""); + }); + + expect( + screen.queryByText(/received from the other application/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/tests/AppFooter.integration.test.tsx b/src/tests/AppFooter.integration.test.tsx new file mode 100644 index 00000000..e2434266 --- /dev/null +++ b/src/tests/AppFooter.integration.test.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import AppFooter from "../components/App/AppFooter"; +import ediTDorContext from "../context/ediTDorContext"; +import { + THING_DESCRIPTION_LAMP_JSON, + THING_DESCRIPTION_LAMP_V_STRING, + createContextValue, +} from "./constants"; + +const renderWithContext = (contextOverrides: Partial = {}) => + render( + + + + ); + +describe("Integration test on rendering elements", () => { + test("renders counts, size, northbound state, version, and GitHub link from context", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + northboundConnection: { + message: "Connected", + northboundTd: THING_DESCRIPTION_LAMP_JSON, + }, + isModified: true, + }); + + expect(screen.getByRole("contentinfo")).toBeInTheDocument(); + expect(screen.getByText("Properties: 1")).toBeInTheDocument(); + expect(screen.getByText("| Actions: 1")).toBeInTheDocument(); + expect(screen.getByText("| Events: 1")).toBeInTheDocument(); + expect( + screen.getByText( + `| Size: ${THING_DESCRIPTION_LAMP_V_STRING.length} bytes` + ) + ).toBeInTheDocument(); + expect( + screen.getByText(/northbound state:\s*connected/i) + ).toBeInTheDocument(); + expect(screen.getByText(/you have unsaved changes/i)).toBeInTheDocument(); + + const githubLink = screen.getByRole("link"); + expect(githubLink).toHaveAttribute( + "href", + "https://github.com/eclipse-editdor/editdor" + ); + }); + + test("renders zero counts and unknown northbound state for an empty TD", () => { + renderWithContext({ + offlineTD: "", + parsedTD: {}, + northboundConnection: undefined, + isModified: false, + }); + + expect(screen.getByRole("contentinfo")).toBeInTheDocument(); + expect(screen.getByText("Properties: 0")).toBeInTheDocument(); + expect(screen.getByText("| Actions: 0")).toBeInTheDocument(); + expect(screen.getByText("| Events: 0")).toBeInTheDocument(); + expect(screen.getByText("| Size: 0 bytes")).toBeInTheDocument(); + expect( + screen.getByText(/northbound state:\s*unknown/i) + ).toBeInTheDocument(); + expect( + screen.queryByText(/you have unsaved changes/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/tests/AppHeader.integration.test.tsx b/src/tests/AppHeader.integration.test.tsx new file mode 100644 index 00000000..699d0a7b --- /dev/null +++ b/src/tests/AppHeader.integration.test.tsx @@ -0,0 +1,474 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import AppHeader from "../components/App/AppHeader"; +import ediTDorContext from "../context/ediTDorContext"; +import * as fileTdService from "../services/fileTdService"; +import { + THING_DESCRIPTION_LAMP_JSON, + THING_DESCRIPTION_LAMP_V_STRING, + createContextValue, +} from "./constants"; + +vi.mock("../services/fileTdService", () => ({ + readFromFile: vi.fn(), + saveToFile: vi.fn(), +})); + +vi.mock("../components/Dialogs/ErrorDialog", () => ({ + default: ({ + isOpen, + errorMessage, + onClose, + }: { + isOpen: boolean; + errorMessage: string; + onClose: () => void; + }) => + isOpen ? ( +
+

{errorMessage}

+ +
+ ) : null, +})); + +vi.mock("../components/Dialogs/ConvertTmDialog", async () => { + const React = await import("react"); + + const MockConvertTmDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Convert TM Dialog
: null; + }); + + return { default: MockConvertTmDialog }; +}); + +vi.mock("../components/Dialogs/CreateTdDialog", async () => { + const React = await import("react"); + + const MockCreateTdDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + })); + + return opened ?
Create TD Dialog
: null; + }); + + return { default: MockCreateTdDialog }; +}); + +vi.mock("../components/Dialogs/SettingsDialog", async () => { + const React = await import("react"); + + const MockSettingsDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Settings Dialog
: null; + }); + + return { default: MockSettingsDialog }; +}); + +vi.mock("../components/Dialogs/ShareDialog", async () => { + const React = await import("react"); + + const MockShareDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + })); + + return opened ?
Share Dialog
: null; + }); + + return { default: MockShareDialog }; +}); + +vi.mock("../components/Dialogs/ContributeToCatalogDialog", async () => { + const React = await import("react"); + + const MockContributeDialog = React.forwardRef((_props, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Contribute To Catalog Dialog
: null; + }); + + return { default: MockContributeDialog }; +}); + +vi.mock("../components/Dialogs/SendTDDialog", async () => { + const React = await import("react"); + + const MockSendTDDialog = React.forwardRef( + ({ currentTdId }: { currentTdId: string }, ref) => { + const [opened, setOpened] = React.useState(false); + + React.useImperativeHandle(ref, () => ({ + openModal: () => setOpened(true), + close: () => setOpened(false), + })); + + return opened ?
Send TD Dialog for {currentTdId}
: null; + } + ); + + return { default: MockSendTDDialog }; +}); + +const mockedReadFromFile = vi.mocked(fileTdService.readFromFile); +const mockedSaveToFile = vi.mocked(fileTdService.saveToFile); + +const renderWithContext = (contextOverrides: Partial = {}) => { + const contextValue = createContextValue(contextOverrides); + + const view = render( + + + + ); + + return { + ...view, + contextValue, + }; +}; + +describe("Integration test on rendering elements, actions and errors", () => { + beforeEach(() => { + localStorage.clear(); + mockedReadFromFile.mockReset(); + mockedSaveToFile.mockReset(); + + Object.defineProperty(window, "confirm", { + configurable: true, + writable: true, + value: vi.fn(() => true), + }); + + Object.defineProperty(window, "open", { + configurable: true, + writable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + test("renders the header and primary actions", () => { + renderWithContext(); + + expect(screen.getByRole("banner")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /logo/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /send td/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /contribute to catalog/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /^share$/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /download/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /settings/i }) + ).toBeInTheDocument(); + }); + + test("opens the project site when the logo button is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /logo/i })); + + expect(window.open).toHaveBeenCalledWith( + "https://eclipse-editdor.github.io/editdor/", + "_blank" + ); + }); + + test("shows the create dialog when Create is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /create/i })); + + expect(screen.getByText("Create TD Dialog")).toBeInTheDocument(); + }); + + test("shows the share dialog when Share is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /^share$/i })); + + expect(screen.getByText("Share Dialog")).toBeInTheDocument(); + }); + + test("shows the settings dialog when Settings is clicked", () => { + renderWithContext(); + + fireEvent.click(screen.getByRole("button", { name: /settings/i })); + + expect(screen.getByText("Settings Dialog")).toBeInTheDocument(); + }); + + test("shows an error when Send TD is clicked without a TD loaded", () => { + renderWithContext({ + offlineTD: "", + parsedTD: {}, + }); + + fireEvent.click(screen.getByRole("button", { name: /send td/i })); + + expect( + screen.getByText(/no thing description available to send/i) + ).toBeInTheDocument(); + }); + + test("shows an error when Send TD is clicked without a configured southbound URL", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + }); + + fireEvent.click(screen.getByRole("button", { name: /send td/i })); + + expect( + screen.getByText(/no southbound url available/i) + ).toBeInTheDocument(); + }); + + test("opens the Send TD dialog when TD, southbound URL, and id are available", () => { + localStorage.setItem("southbound", "http://localhost:8080"); + + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + }); + + fireEvent.click(screen.getByRole("button", { name: /send td/i })); + + expect( + screen.getByText(`Send TD Dialog for ${THING_DESCRIPTION_LAMP_JSON.id}`) + ).toBeInTheDocument(); + }); + + test("shows an error when Contribute to Catalog is clicked without a loaded TM", () => { + renderWithContext({ + offlineTD: "", + parsedTD: {}, + }); + + fireEvent.click( + screen.getByRole("button", { name: /contribute to catalog/i }) + ); + + expect( + screen.getByText(/please first load a thing model to be validated/i) + ).toBeInTheDocument(); + }); + + test("shows an error when Contribute to Catalog is clicked for a non-TM document", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: THING_DESCRIPTION_LAMP_JSON, + }); + + fireEvent.click( + screen.getByRole("button", { name: /contribute to catalog/i }) + ); + + expect( + screen.getByText(/the tm must have the following pair key\/value/i) + ).toBeInTheDocument(); + }); + + test("opens the Contribute to Catalog dialog for a valid TM", () => { + renderWithContext({ + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + parsedTD: { + ...THING_DESCRIPTION_LAMP_JSON, + "@type": "tm:ThingModel", + }, + validationMessage: { + report: { + json: null, + schema: "passed", + defaults: null, + jsonld: null, + additional: null, + }, + details: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + detailComments: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + validationErrors: { + json: "", + schema: "", + }, + customMessage: "", + }, + }); + + fireEvent.click( + screen.getByRole("button", { name: /contribute to catalog/i }) + ); + + expect( + screen.getByText("Contribute To Catalog Dialog") + ).toBeInTheDocument(); + }); + + test("renders the To TD button only for Thing Models", () => { + const { rerender } = render( + + + + ); + + expect( + screen.queryByRole("button", { name: /to td/i }) + ).not.toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByRole("button", { name: /to td/i })).toBeInTheDocument(); + }); + + test("opens the Convert TM dialog when To TD is clicked", () => { + renderWithContext({ + parsedTD: { + ...THING_DESCRIPTION_LAMP_JSON, + "@type": "tm:ThingModel", + }, + }); + + fireEvent.click(screen.getByRole("button", { name: /to td/i })); + + expect(screen.getByText("Convert TM Dialog")).toBeInTheDocument(); + }); + + test("opens a TD from file and updates context state", async () => { + mockedReadFromFile.mockResolvedValue({ + td: THING_DESCRIPTION_LAMP_V_STRING, + fileName: "lamp.jsonld", + fileHandle: "mock-handle", + }); + + const { contextValue } = renderWithContext({ + isModified: false, + }); + + fireEvent.click(screen.getByRole("button", { name: /open/i })); + + await waitFor(() => { + expect(mockedReadFromFile).toHaveBeenCalled(); + }); + + expect(contextValue.updateOfflineTD).toHaveBeenCalledWith( + THING_DESCRIPTION_LAMP_V_STRING + ); + expect(contextValue.updateIsModified).toHaveBeenCalledWith(false); + expect(contextValue.setFileHandle).toHaveBeenCalledWith("mock-handle"); + expect(contextValue.updateLinkedTd).toHaveBeenCalledWith(undefined); + expect(contextValue.addLinkedTd).toHaveBeenCalledWith({ + "./lamp.jsonld": "mock-handle", + }); + }); + + test("asks for confirmation before opening a new file when the TD is modified", async () => { + mockedReadFromFile.mockResolvedValue({ + td: THING_DESCRIPTION_LAMP_V_STRING, + fileName: "lamp.jsonld", + fileHandle: "mock-handle", + }); + + renderWithContext({ + isModified: true, + }); + + fireEvent.click(screen.getByRole("button", { name: /open/i })); + + await waitFor(() => { + expect(window.confirm).toHaveBeenCalledWith( + "Discard changes? All changes you made to your TD will be lost." + ); + }); + }); + + test("saves the current TD when Download is clicked", async () => { + mockedSaveToFile.mockResolvedValue("saved-handle"); + + const { contextValue } = renderWithContext({ + name: "lamp", + offlineTD: THING_DESCRIPTION_LAMP_V_STRING, + fileHandle: null, + }); + + fireEvent.click(screen.getByRole("button", { name: /download/i })); + + await waitFor(() => { + expect(mockedSaveToFile).toHaveBeenCalledWith( + "lamp", + undefined, + THING_DESCRIPTION_LAMP_V_STRING + ); + }); + + expect(contextValue.setFileHandle).toHaveBeenCalledWith("saved-handle"); + expect(contextValue.updateIsModified).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/tests/constants.tsx b/src/tests/constants.tsx new file mode 100644 index 00000000..574241c0 --- /dev/null +++ b/src/tests/constants.tsx @@ -0,0 +1,124 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and + * + * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 + ********************************************************************************/ +import { vi } from "vitest"; +export const THING_DESCRIPTION_LAMP_JSON = { + "@context": "https://www.w3.org/ns/wot-next/td", + id: "urn:uuid:0804d572-cce8-422a-bb7c-4412fcd56f06", + title: "MyLampThing", + securityDefinitions: { + basic_sc: { scheme: "basic", in: "header" }, + }, + security: "basic_sc", + properties: { + status: { + type: "string", + forms: [{ href: "https://mylamp.example.com/status" }], + }, + }, + actions: { + toggle: { + forms: [{ href: "https://mylamp.example.com/toggle" }], + }, + }, + events: { + overheating: { + data: { type: "string" }, + forms: [ + { + href: "https://mylamp.example.com/oh", + subprotocol: "longpoll", + }, + ], + }, + }, +}; + +export const THING_DESCRIPTION_LAMP_V_STRING = JSON.stringify( + THING_DESCRIPTION_LAMP_JSON, + null, + 2 +); + +export const createContextValue = ( + overrides: Partial = {} +): IEdiTDorContext => ({ + offlineTD: "", + isValidJSON: true, + parsedTD: {}, + isModified: false, + name: "", + fileHandle: null, + linkedTd: undefined, + validationMessage: { + report: { + json: null, + schema: null, + defaults: null, + jsonld: null, + additional: null, + }, + details: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + detailComments: { + enumConst: null, + propItems: null, + security: null, + propUniqueness: null, + multiLangConsistency: null, + linksRelTypeCount: null, + readWriteOnly: null, + uriVariableSecurity: null, + }, + validationErrors: { + json: "", + schema: "", + }, + customMessage: "", + }, + northboundConnection: { + message: "", + northboundTd: {}, + }, + contributeCatalog: { + model: "", + author: "", + manufacturer: "", + license: "", + copyrightYear: "", + holder: "", + tmCatalogEndpoint: "", + nameRepository: "", + dynamicValues: {}, + }, + updateOfflineTD: vi.fn(), + updateIsModified: vi.fn(), + setFileHandle: vi.fn(), + removeForm: vi.fn(), + addForm: vi.fn(), + removeLink: vi.fn(), + removeOneOfAKindReducer: vi.fn(), + addLinkedTd: vi.fn(), + updateLinkedTd: vi.fn(), + updateValidationMessage: vi.fn(), + updateNorthboundConnection: vi.fn(), + updateContributeCatalog: vi.fn(), + ...overrides, +}); diff --git a/src/utils/parser.ts b/src/utils/parser.ts index a3a3055d..de79d158 100644 --- a/src/utils/parser.ts +++ b/src/utils/parser.ts @@ -85,9 +85,7 @@ export const parseCsv = ( dynamicTyping: false, transformHeader: (h) => h.trim(), transform: (value) => (typeof value === "string" ? value.trim() : value), - complete: (results) => { - console.log(results.data, results.errors, results.meta); - }, + complete: () => {}, }); if (res.errors.length) { diff --git a/yarn.lock b/yarn.lock index 4d2c97a1..b3ad6b24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31,6 +31,15 @@ "@csstools/css-tokenizer" "^3.0.3" lru-cache "^10.4.3" +"@babel/code-frame@^7.10.4": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" @@ -1088,6 +1097,20 @@ resolved "https://registry.yarnpkg.com/@servie/events/-/events-1.0.0.tgz#8258684b52d418ab7b86533e861186638ecc5dc1" integrity sha512-sBSO19KzdrJCM3gdx6eIxV8M9Gxfgg6iDQmH5TIAGaUu+X9VDdsINXJOnoiZ1Kx3TrHdH4bt5UVglkjsEGBcvw== +"@testing-library/dom@^10.4.1": + version "10.4.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" + integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.3.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + picocolors "1.1.1" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^6.8.0": version "6.9.1" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz#7613a04e146dd2976d24ddf019730d57a89d56c2" @@ -1120,6 +1143,11 @@ wot-thing-model-types "^1.1.0-12-March-2025" wot-typescript-definitions "^0.8.0-SNAPSHOT.30" +"@types/aria-query@^5.0.1": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" + integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== + "@types/babel__core@^7", "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1432,6 +1460,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + ansi-styles@^6.1.0: version "6.2.3" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" @@ -1460,6 +1493,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-query@5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + aria-query@^5.0.0: version "5.3.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" @@ -1943,6 +1983,11 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + didyoumean@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" @@ -1977,6 +2022,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.9: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + dom-accessibility-api@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" @@ -3682,7 +3732,7 @@ pathval@^2.0.0: resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== -picocolors@^1.0.0, picocolors@^1.1.1: +picocolors@1.1.1, picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -3847,6 +3897,15 @@ prettier@^3.7.4: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.7.4.tgz#d2f8335d4b1cec47e1c8098645411b0c9dff9c0f" integrity sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA== +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + 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" @@ -3942,6 +4001,11 @@ react-is@^16.13.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-papaparse@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/react-papaparse/-/react-papaparse-4.4.0.tgz#754b18c62240782d9b3b0bbc132b08ed6ec8ca13"