diff --git a/v3/package-lock.json b/v3/package-lock.json index 1fed35fdb..1bc0e1e49 100644 --- a/v3/package-lock.json +++ b/v3/package-lock.json @@ -104,6 +104,7 @@ "jest": "^29.7.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "jest-webgl-canvas-mock": "^2.5.3", "json5-loader": "^4.0.1", "mini-css-extract-plugin": "^2.9.0", @@ -10084,6 +10085,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -16418,6 +16428,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -18859,8 +18879,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -18880,25 +18898,19 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/node-fetch/node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/node-fetch/node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -20533,6 +20545,12 @@ "node": ">=8" } }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -32162,6 +32180,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "cross-fetch": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.8.tgz", + "integrity": "sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==", + "dev": true, + "requires": { + "node-fetch": "^2.6.12" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -36773,6 +36800,16 @@ "jest-util": "^29.7.0" } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -38631,8 +38668,6 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dev": true, - "optional": true, - "peer": true, "requires": { "whatwg-url": "^5.0.0" }, @@ -38641,25 +38676,19 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dev": true, - "optional": true, - "peer": true, "requires": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" @@ -39858,6 +39887,12 @@ "fromentries": "^1.2.0" } }, + "promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true + }, "prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/v3/package.json b/v3/package.json index e46e8e86d..c2b38f0dd 100644 --- a/v3/package.json +++ b/v3/package.json @@ -153,6 +153,7 @@ "jest": "^29.7.0", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "jest-webgl-canvas-mock": "^2.5.3", "json5-loader": "^4.0.1", "mini-css-extract-plugin": "^2.9.0", diff --git a/v3/src/components/tool-shelf/plugins-button.test.tsx b/v3/src/components/tool-shelf/plugins-button.test.tsx new file mode 100644 index 000000000..32debe126 --- /dev/null +++ b/v3/src/components/tool-shelf/plugins-button.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { userEvent } from "@testing-library/user-event" +import React from "react" +import { PluginsButton } from "./plugins-button" +import { t } from "../../utilities/translation/translate" +import { PluginData } from "../../hooks/use-standard-plugins" + +describe("PluginsButtons", () => { + const user = userEvent.setup() + + it("renders with no plugins", async () => { + // fetch returns empty plugins array + fetchMock.mockResponseOnce("[]") + render() + await waitFor(() => { + expect(screen.getByTestId("tool-shelf-button-plugins")).toBeInTheDocument() + }) + // click the button + user.click(screen.getByTestId("tool-shelf-button-plugins")) + await waitFor(() => { + expect(screen.getByText(t("V3.ToolButtonData.pluginMenu.fetchError"))).toBeInTheDocument() + }) + }) + + it("renders with a plugin", async () => { + // fetch returns an array with a single plugin + const plugins: PluginData[] = [{ + categories: [], + description: "", + height: 100, + icon: "", + isStandard: "true", + path: "", + title: "Test Plugin", + visible: "true", + width: 100 + }] + fetchMock.mockResponseOnce(JSON.stringify(plugins)) + render() + await waitFor(() => { + expect(screen.getByTestId("tool-shelf-button-plugins")).toBeInTheDocument() + }) + // click the button + user.click(screen.getByTestId("tool-shelf-button-plugins")) + await waitFor(() => { + expect(screen.getByText("Test Plugin")).toBeInTheDocument() + }) + }) +}) diff --git a/v3/src/components/tool-shelf/plugins-button.tsx b/v3/src/components/tool-shelf/plugins-button.tsx index e8c26528e..bc7013853 100644 --- a/v3/src/components/tool-shelf/plugins-button.tsx +++ b/v3/src/components/tool-shelf/plugins-button.tsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState } from "react" +import React from "react" import { Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react" import PluginsIcon from '../../assets/icons/icon-plug.svg' +import { PluginData, useStandardPlugins } from "../../hooks/use-standard-plugins" import { useDocumentContent } from "../../hooks/use-document-content" import { t } from "../../utilities/translation/translate" import { kWebViewTileType } from "../web-view/web-view-defs" @@ -10,27 +11,10 @@ import { ToolShelfButtonTag } from "./tool-shelf-button" import "./plugins-button.scss" -const pluginDataUrl = `${kRootPluginUrl}/published-plugins.json` - -interface PluginData { - aegis?: string, - categories: string[], - description: string, - "description-string"?: string, - height: number, - icon: string, - isStandard: "true" | "false", // All have "true" for some reason - path: string, - title: string, - "title-string"?: string, - visible: boolean | "true" | "false", // Most have "true" or "false" for some reason, but a couple have true - width: number -} - -interface IPluginSelectionProps { +interface IPluginItemProps { pluginData: PluginData } -function PluginSelection({ pluginData }: IPluginSelectionProps) { +function PluginItem({ pluginData }: IPluginItemProps) { const documentContent = useDocumentContent() function handleClick() { @@ -62,17 +46,7 @@ function PluginSelection({ pluginData }: IPluginSelectionProps) { } export function PluginsButton() { - const [pluginData, setPluginData] = useState([]) - - useEffect(() => { - try { - fetch(pluginDataUrl) - .then(response => response.json()) - .then(json => setPluginData(json)) - } catch (error) { - console.warn("Unable to load plugin data.", error) - } - }, []) + const { plugins } = useStandardPlugins() return ( @@ -85,7 +59,11 @@ export function PluginsButton() { - {pluginData.map(pd => )} + { + plugins.length + ? plugins.map(pd => ) + : {t("V3.ToolButtonData.pluginMenu.fetchError")} + } ) diff --git a/v3/src/hooks/use-standard-plugins.test.ts b/v3/src/hooks/use-standard-plugins.test.ts new file mode 100644 index 000000000..0387a7a4c --- /dev/null +++ b/v3/src/hooks/use-standard-plugins.test.ts @@ -0,0 +1,40 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { useStandardPlugins } from "./use-standard-plugins" + +describe("useStandardPlugins", () => { + + it("handles fetch throwing an error", async () => { + fetchMock.mockRejectOnce(new Error()) + const spy = jest.spyOn(console, "warn").mockImplementation(() => null) + const { result } = renderHook(() => useStandardPlugins()) + await waitFor(() => { + expect(result.current.status).not.toBe("pending") + }) + expect(result.current.status).toBe("error") + expect(spy).toHaveBeenCalledTimes(1) + spy.mockRestore() + }) + + it("handles fetch returning !ok response", async () => { + // !ok response from fetch + fetchMock.mockResponseOnce("[]", { status: 500 }) + const spy = jest.spyOn(console, "warn").mockImplementation(() => null) + const { result } = renderHook(() => useStandardPlugins()) + await waitFor(() => { + expect(result.current.status).not.toBe("pending") + }) + expect(result.current.status).toBe("error") + expect(spy).toHaveBeenCalledTimes(1) + spy.mockRestore() + }) + + it("handles fetch returning empty plugins array", async () => { + fetchMock.mockResponseOnce("[]") + const { result } = renderHook(() => useStandardPlugins()) + await waitFor(() => { + expect(result.current.status).not.toBe("pending") + }) + expect(result.current).toEqual({ status: "complete", plugins: [] }) + }) + +}) diff --git a/v3/src/hooks/use-standard-plugins.ts b/v3/src/hooks/use-standard-plugins.ts new file mode 100644 index 000000000..eee345c26 --- /dev/null +++ b/v3/src/hooks/use-standard-plugins.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react" +import { kRootPluginUrl } from "../components/web-view/web-view-utils" + +const pluginConfigUrl = `${kRootPluginUrl}/published-plugins.json` + +export interface PluginData { + aegis?: string, + categories: string[], + description: string, + "description-string"?: string, + height: number, + icon: string, + isStandard: "true" | "false", // All have "true" for some reason + path: string, + title: string, + "title-string"?: string, + visible: boolean | "true" | "false", // Most have "true" or "false" for some reason, but a couple have true + width: number +} + +export function useStandardPlugins() { + const [status, setStatus] = useState<"pending" | "complete" | "error">("pending") + const [plugins, setPlugins] = useState([]) + + useEffect(() => { + async function retrievePluginConfig() { + try { + const response = await fetch(pluginConfigUrl) + if (!response.ok) throw new Error(`Network error: ${response.status}`) + const jsonResponse = await response.json() + setPlugins(jsonResponse) + setStatus("complete") + } catch (error) { + console.warn("Unable to load plugin data:", error) + setStatus("error") + } + } + + // TODO: retry periodically on failure; perhaps a built-in default configuration + retrievePluginConfig() + }, []) + + return { status, plugins } +} diff --git a/v3/src/test/setupTests.ts b/v3/src/test/setupTests.ts index db750f808..16a8c8604 100644 --- a/v3/src/test/setupTests.ts +++ b/v3/src/test/setupTests.ts @@ -1,5 +1,6 @@ import "@testing-library/jest-dom" import "jest-canvas-mock" +import { enableFetchMocks } from "jest-fetch-mock" import "jest-webgl-canvas-mock" import { isEqual, isEqualWith } from "lodash" import ResizeObserverPolyfill from "resize-observer-polyfill" @@ -12,6 +13,9 @@ global.ResizeObserver = ResizeObserverPolyfill // mock DOM APIs not supported by JSDOM Element.prototype.scrollIntoView = jest.fn() +// enable fetch mocking +enableFetchMocks() + declare global { function assertIsDefined(value: T): asserts value is NonNullable function jestSpyConsole(method: ConsoleMethod, fn: JestSpyConsoleFn, diff --git a/v3/src/utilities/translation/lang/en-US.json5 b/v3/src/utilities/translation/lang/en-US.json5 index dccd85d66..20c1b3129 100644 --- a/v3/src/utilities/translation/lang/en-US.json5 +++ b/v3/src/utilities/translation/lang/en-US.json5 @@ -1,12 +1,8 @@ { // CODAP V3 temporary strings (not for translation, at least not yet) "V3.caseTable.noCases": "no cases", - "V3.summary.parseResults": "Parsed \"%@1\" with %@2 %@3 (%@4 selected) and...", - "V3.summary.noData": "No data", - "V3.summary.attributes": "Attributes", - "V3.summary.attributeInspector": "Attribute Inspector", - "V3.summary.startProfiling": "Start Profiling", - "V3.summary.stopProfiling": "Stop Profiling", + + "V3.ToolButtonData.pluginMenu.fetchError": "No plugin configuration available", // V3 general strings that are not present in V2 and will require translation "V3.general.yes": "Yes",