From 25de329963a448936f2d33cefa8cd10b4f273da4 Mon Sep 17 00:00:00 2001 From: Kirk Swenson Date: Mon, 19 Aug 2024 10:08:37 -0700 Subject: [PATCH] feat: Update plugins menu configuration (#1410) * feat: plugins menu config hosted locally; support morePlugins url param * chore: update cypress tests * chore: support absolute urls in addition to relative urls * chore: self code review tweaks --- v3/cypress/e2e/adornments.spec.ts | 2 - v3/cypress/e2e/attribute-types.spec.ts | 2 - v3/cypress/e2e/bivariate-adornments.spec.ts | 2 - v3/cypress/e2e/calculator.spec.ts | 2 - v3/cypress/e2e/case-card.spec.ts | 2 - v3/cypress/e2e/cfm.spec.ts | 2 - v3/cypress/e2e/component.spec.ts | 2 - v3/cypress/e2e/graph-legend.spec.ts | 2 - v3/cypress/e2e/graph.spec.ts | 2 - v3/cypress/e2e/hierarchical-table.spec.ts | 2 - v3/cypress/e2e/map.spec.ts | 2 - v3/cypress/e2e/plugin.spec.ts | 2 - v3/cypress/e2e/slider.spec.ts | 2 - v3/cypress/e2e/table.spec.ts | 5 --- v3/cypress/e2e/tool-shelf.spec.ts | 2 - .../tool-shelf/plugin-config-types.ts | 16 +++++++ .../tool-shelf/plugins-button.test.tsx | 38 +--------------- .../components/tool-shelf/plugins-button.tsx | 31 ++++++++----- .../tool-shelf/standard-plugins.json} | 0 ...t.ts => use-remote-plugins-config.test.ts} | 16 +++++-- v3/src/hooks/use-remote-plugins-config.ts | 36 +++++++++++++++ v3/src/hooks/use-standard-plugins.ts | 44 ------------------- 22 files changed, 85 insertions(+), 129 deletions(-) create mode 100644 v3/src/components/tool-shelf/plugin-config-types.ts rename v3/{cypress/fixtures/mockPublishedPlugins.json => src/components/tool-shelf/standard-plugins.json} (100%) rename v3/src/hooks/{use-standard-plugins.test.ts => use-remote-plugins-config.test.ts} (68%) create mode 100644 v3/src/hooks/use-remote-plugins-config.ts delete mode 100644 v3/src/hooks/use-standard-plugins.ts diff --git a/v3/cypress/e2e/adornments.spec.ts b/v3/cypress/e2e/adornments.spec.ts index 373cabebcc..8008547fb8 100644 --- a/v3/cypress/e2e/adornments.spec.ts +++ b/v3/cypress/e2e/adornments.spec.ts @@ -5,12 +5,10 @@ import { ToolbarElements as toolbar } from "../support/elements/toolbar-elements context("Graph adornments", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?sample=mammals&dashboard&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) - cy.log('Setup complete') }) it("shows inspector palette when Display Values button is clicked", () => { diff --git a/v3/cypress/e2e/attribute-types.spec.ts b/v3/cypress/e2e/attribute-types.spec.ts index a0dbcf4df2..cf54dc5562 100644 --- a/v3/cypress/e2e/attribute-types.spec.ts +++ b/v3/cypress/e2e/attribute-types.spec.ts @@ -3,13 +3,11 @@ import { CfmElements as cfm } from "../support/elements/cfm" context("attribute types", () => { beforeEach(() => { - cy.log('Starting test setup') const filename = "cypress/fixtures/attribute-types.codap" const url = `${Cypress.config("index")}` cy.visit(url) cy.wait(3000) cfm.openLocalDoc(filename) - cy.log('Setup complete') }) describe("attribute types are rendered correctly", () => { diff --git a/v3/cypress/e2e/bivariate-adornments.spec.ts b/v3/cypress/e2e/bivariate-adornments.spec.ts index 4ef172e5c9..6f52254d9a 100644 --- a/v3/cypress/e2e/bivariate-adornments.spec.ts +++ b/v3/cypress/e2e/bivariate-adornments.spec.ts @@ -4,12 +4,10 @@ import { ToolbarElements as toolbar } from "../support/elements/toolbar-elements context("Graph adornments", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?sample=mammals&dashboard&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) - cy.log('Setup complete') }) it("adds a least squares line to graph when Least Squares Line checkbox is checked", () => { diff --git a/v3/cypress/e2e/calculator.spec.ts b/v3/cypress/e2e/calculator.spec.ts index 16698883ea..4f4d7b2d33 100644 --- a/v3/cypress/e2e/calculator.spec.ts +++ b/v3/cypress/e2e/calculator.spec.ts @@ -6,12 +6,10 @@ const calculatorName = "Calculator" context("Calculator", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?sample=mammals&dashboard&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) // Ensuring the page and components are fully loaded. - cy.log('Setup complete') }) it("populates default title", () => { c.getComponentTitle("calculator").should("contain", calculatorName) diff --git a/v3/cypress/e2e/case-card.spec.ts b/v3/cypress/e2e/case-card.spec.ts index 42f51b749e..12660ddbe4 100644 --- a/v3/cypress/e2e/case-card.spec.ts +++ b/v3/cypress/e2e/case-card.spec.ts @@ -1,12 +1,10 @@ beforeEach(() => { // cy.scrollTo() doesn't work as expected with `scroll-behavior: smooth` - cy.log('Starting test setup') const queryParams = "?sample=mammals&scrollBehavior=auto" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2000) - cy.log('Setup complete') }) context("case card", () => { diff --git a/v3/cypress/e2e/cfm.spec.ts b/v3/cypress/e2e/cfm.spec.ts index 910ec66841..01d46933f6 100644 --- a/v3/cypress/e2e/cfm.spec.ts +++ b/v3/cypress/e2e/cfm.spec.ts @@ -4,12 +4,10 @@ import { TableTileElements as table } from "../support/elements/table-tile" context("CloudFileManager", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) - cy.log('Setup complete') }) it("Opens Mammals example document via CFM Open dialog", () => { // hamburger menu is hidden initially diff --git a/v3/cypress/e2e/component.spec.ts b/v3/cypress/e2e/component.spec.ts index 646229a3d0..01ce1376ac 100644 --- a/v3/cypress/e2e/component.spec.ts +++ b/v3/cypress/e2e/component.spec.ts @@ -2,12 +2,10 @@ import { ComponentElements as c } from "../support/elements/component-elements" context("Component UI", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?sample=mammals&dashboard&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) - cy.log('Setup complete') }) it("moves components by dragging", () => { diff --git a/v3/cypress/e2e/graph-legend.spec.ts b/v3/cypress/e2e/graph-legend.spec.ts index 48ff7673b9..0741b63676 100644 --- a/v3/cypress/e2e/graph-legend.spec.ts +++ b/v3/cypress/e2e/graph-legend.spec.ts @@ -18,12 +18,10 @@ const arrayOfValues = [ context("Test legend with various attribute types", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?sample=mammals&dashboard&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) - cy.log('Setup complete') }) it("will not draw legend if plot area is empty", () => { glh.dragAttributeToPlot(arrayOfAttributes[7]) // Habitat => plot area diff --git a/v3/cypress/e2e/graph.spec.ts b/v3/cypress/e2e/graph.spec.ts index 9b9fd87a3e..fc66e6492c 100644 --- a/v3/cypress/e2e/graph.spec.ts +++ b/v3/cypress/e2e/graph.spec.ts @@ -15,13 +15,11 @@ const plots = graphRules.plots // (In local, this works fine and the tests can be run successfully) context.skip("Test graph plot transitions", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cfm.openLocalDoc("cypress/fixtures/3TableGroups.codap") cy.wait(2500) - cy.log('Setup complete') }) plots.forEach(test => { diff --git a/v3/cypress/e2e/hierarchical-table.spec.ts b/v3/cypress/e2e/hierarchical-table.spec.ts index ff53da69df..a48464fbc6 100644 --- a/v3/cypress/e2e/hierarchical-table.spec.ts +++ b/v3/cypress/e2e/hierarchical-table.spec.ts @@ -6,12 +6,10 @@ const values = hierarchical.attributes context("hierarchical collections", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?sample=mammals&dashboard&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) - cy.log('Setup complete') }) hierarchical.tests.forEach((h: HierarchicalTest) => { // FIXME: enable skipped tests diff --git a/v3/cypress/e2e/map.spec.ts b/v3/cypress/e2e/map.spec.ts index 9428831617..6e9e58a7c3 100644 --- a/v3/cypress/e2e/map.spec.ts +++ b/v3/cypress/e2e/map.spec.ts @@ -17,11 +17,9 @@ const arrayOfAttributes = ["Category", "Educ_Tertiary_Perc", "Inversions"] context("Map UI", () => { beforeEach(function () { - cy.log('Starting test setup') const url = `${Cypress.config("index")}?mouseSensor&noComponentAnimation` cy.visit(url) cy.wait(3000) - cy.log('Setup complete') }) it("verify map title", () => { diff --git a/v3/cypress/e2e/plugin.spec.ts b/v3/cypress/e2e/plugin.spec.ts index 42cae6970c..75b7f5810c 100644 --- a/v3/cypress/e2e/plugin.spec.ts +++ b/v3/cypress/e2e/plugin.spec.ts @@ -7,10 +7,8 @@ import { WebViewTileElements as webView } from "../support/elements/web-view-til context("codap plugins", () => { beforeEach(function () { - cy.log('Starting test setup') const url = `${Cypress.config("index")}?sample=mammals&dashboard` cy.visit(url) - cy.log('Setup complete') }) const openAPITester = () => { const url='https://concord-consortium.github.io/codap-data-interactives/DataInteractiveAPITester/index.html?lang=en' diff --git a/v3/cypress/e2e/slider.spec.ts b/v3/cypress/e2e/slider.spec.ts index d57dfd89bb..a5030dfe55 100644 --- a/v3/cypress/e2e/slider.spec.ts +++ b/v3/cypress/e2e/slider.spec.ts @@ -10,12 +10,10 @@ const newSliderValue = "0.6" context("Slider UI", () => { beforeEach(function () { - cy.log('Starting test setup') const queryParams = "?sample=mammals&dashboard&mouseSensor" const url = `${Cypress.config("index")}${queryParams}` cy.visit(url) cy.wait(2500) - cy.log('Setup complete') }) it("populates default title, variable name and value", () => { c.getComponentTitle("slider").should("contain", sliderName) diff --git a/v3/cypress/e2e/table.spec.ts b/v3/cypress/e2e/table.spec.ts index 9269237ddc..7f6bbe8fb2 100644 --- a/v3/cypress/e2e/table.spec.ts +++ b/v3/cypress/e2e/table.spec.ts @@ -14,12 +14,8 @@ const newCollectionName = "New Dataset" beforeEach(() => { // cy.scrollTo() doesn't work as expected with `scroll-behavior: smooth` - cy.log('Starting test setup') const queryParams = "?sample=mammals&scrollBehavior=auto" const url = `${Cypress.config("index")}${queryParams}` - cy.intercept("GET", "https://codap-resources.s3.amazonaws.com/plugins/published-plugins.json", { - fixture: "mockPublishedPlugins.json" - }) cy.visit(url) cy.wait(1000) table.getNumOfAttributes().should("equal", numOfAttributes.toString()) @@ -28,7 +24,6 @@ beforeEach(() => { lastRowIndex = Number($cases) - 1 middleRowIndex = Math.min(5, Math.floor(lastRowIndex / 2)) }) - cy.log('Setup complete') }) context("case table ui", () => { diff --git a/v3/cypress/e2e/tool-shelf.spec.ts b/v3/cypress/e2e/tool-shelf.spec.ts index 632560fa2c..e31bc065df 100644 --- a/v3/cypress/e2e/tool-shelf.spec.ts +++ b/v3/cypress/e2e/tool-shelf.spec.ts @@ -9,10 +9,8 @@ import { WebViewTileElements as webView } from "../support/elements/web-view-til context("codap toolbar", () => { beforeEach(function () { - cy.log('Starting test setup') const url = `${Cypress.config("index")}?mouseSensor` cy.visit(url) - cy.log('Setup complete') }) it("will open a new table", () => { c.clickIconFromToolShelf("table") diff --git a/v3/src/components/tool-shelf/plugin-config-types.ts b/v3/src/components/tool-shelf/plugin-config-types.ts new file mode 100644 index 0000000000..2a1af5acc7 --- /dev/null +++ b/v3/src/components/tool-shelf/plugin-config-types.ts @@ -0,0 +1,16 @@ +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 type PluginMenuConfig = PluginData[] diff --git a/v3/src/components/tool-shelf/plugins-button.test.tsx b/v3/src/components/tool-shelf/plugins-button.test.tsx index 78d4d1c56b..7b6304c688 100644 --- a/v3/src/components/tool-shelf/plugins-button.test.tsx +++ b/v3/src/components/tool-shelf/plugins-button.test.tsx @@ -2,49 +2,15 @@ import { render, screen } 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("[]") + it("renders with standard plugins", async () => { render() expect(await screen.findByTestId("tool-shelf-button-plugins")).toBeInTheDocument() // click the button user.click(screen.getByTestId("tool-shelf-button-plugins")) - expect(await screen.findByText(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)) - const errorSpy = jest.spyOn(console, "error").mockImplementation(() => null) - render() - expect(await screen.findByTestId("tool-shelf-button-plugins")).toBeInTheDocument() - // click the button - user.click(screen.getByTestId("tool-shelf-button-plugins")) - expect(await screen.findByText("Test Plugin")).toBeInTheDocument() - // I spent a while trying to find a better solution to this problem without success. - // The problem is complicated by the fact that the setState call at issue comes from - // Chakra's menu component rather than our own components. In the end, we just - // suppress the error and assert that it's the only error that occurred. - expect(errorSpy).toHaveBeenCalledTimes(1) - const expectedErrorStr = "Warning: An update to %s inside a test was not wrapped in act(...)" - expect(errorSpy.mock.calls[0][0].startsWith(expectedErrorStr)).toBe(true) - errorSpy.mockRestore() + expect(await screen.findByText("Sampler")).toBeInTheDocument() }) }) diff --git a/v3/src/components/tool-shelf/plugins-button.tsx b/v3/src/components/tool-shelf/plugins-button.tsx index bc70138538..e13cb58c5c 100644 --- a/v3/src/components/tool-shelf/plugins-button.tsx +++ b/v3/src/components/tool-shelf/plugins-button.tsx @@ -1,30 +1,35 @@ import React from "react" -import { Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react" +import { Menu, MenuButton, MenuDivider, MenuItem, MenuList } from "@chakra-ui/react" import PluginsIcon from '../../assets/icons/icon-plug.svg' -import { PluginData, useStandardPlugins } from "../../hooks/use-standard-plugins" +import { useRemotePluginsConfig } from "../../hooks/use-remote-plugins-config" import { useDocumentContent } from "../../hooks/use-document-content" import { t } from "../../utilities/translation/translate" import { kWebViewTileType } from "../web-view/web-view-defs" -import { IWebViewModel } from "../web-view/web-view-model" +import { isWebViewModel } from "../web-view/web-view-model" import { kRootPluginUrl, processPluginUrl } from "../web-view/web-view-utils" import { ToolShelfButtonTag } from "./tool-shelf-button" +import { PluginData, PluginMenuConfig } from "./plugin-config-types" +import _standardPlugins from "./standard-plugins.json" +const standardPlugins = _standardPlugins as PluginMenuConfig import "./plugins-button.scss" interface IPluginItemProps { - pluginData: PluginData + pluginData: PluginData | null } function PluginItem({ pluginData }: IPluginItemProps) { const documentContent = useDocumentContent() function handleClick() { + if (!pluginData) return documentContent?.applyModelChange( () => { - const baseUrl = `${kRootPluginUrl}${pluginData.path}` - const url = processPluginUrl(baseUrl) + const url = URL.canParse(pluginData.path) + ? pluginData.path + : processPluginUrl(`${kRootPluginUrl}${pluginData.path}`) const options = { height: pluginData.height, width: pluginData.width } const tile = documentContent?.createOrShowTile?.(kWebViewTileType, options) - if (tile) (tile.content as IWebViewModel).setUrl(url) + if (isWebViewModel(tile?.content)) tile.content.setUrl(url) }, { undoStringKey: t("V3.Undo.plugin.create", { vars: [pluginData.title] }), redoStringKey: t("V3.Redo.plugin.create", { vars: [pluginData.title] }) @@ -32,7 +37,7 @@ function PluginItem({ pluginData }: IPluginItemProps) { ) } - return ( + return pluginData ? ( {pluginData.title} - ) + ) : } export function PluginsButton() { - const { plugins } = useStandardPlugins() + const { plugins: remotePlugins } = useRemotePluginsConfig() + const pluginItems: Array = + remotePlugins.length ? [...standardPlugins, null, ...remotePlugins] : standardPlugins return ( @@ -60,8 +67,8 @@ export function PluginsButton() { { - plugins.length - ? plugins.map(pd => ) + pluginItems.length + ? pluginItems.map((pd, i) => ) : {t("V3.ToolButtonData.pluginMenu.fetchError")} } diff --git a/v3/cypress/fixtures/mockPublishedPlugins.json b/v3/src/components/tool-shelf/standard-plugins.json similarity index 100% rename from v3/cypress/fixtures/mockPublishedPlugins.json rename to v3/src/components/tool-shelf/standard-plugins.json diff --git a/v3/src/hooks/use-standard-plugins.test.ts b/v3/src/hooks/use-remote-plugins-config.test.ts similarity index 68% rename from v3/src/hooks/use-standard-plugins.test.ts rename to v3/src/hooks/use-remote-plugins-config.test.ts index 0387a7a4ca..cf7ebdfce3 100644 --- a/v3/src/hooks/use-standard-plugins.test.ts +++ b/v3/src/hooks/use-remote-plugins-config.test.ts @@ -1,12 +1,20 @@ import { renderHook, waitFor } from "@testing-library/react" -import { useStandardPlugins } from "./use-standard-plugins" +import { useRemotePluginsConfig } from "./use-remote-plugins-config" + +// mock urlParams to have a morePlugins parameter +// url doesn't matter since response is mocked below +jest.mock("../utilities/url-params", () => ({ + urlParams: { + morePlugins: "https://codap-resources.s3.amazonaws.com/plugins/published-plugins.json" + } +})) 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()) + const { result } = renderHook(() => useRemotePluginsConfig()) await waitFor(() => { expect(result.current.status).not.toBe("pending") }) @@ -19,7 +27,7 @@ describe("useStandardPlugins", () => { // !ok response from fetch fetchMock.mockResponseOnce("[]", { status: 500 }) const spy = jest.spyOn(console, "warn").mockImplementation(() => null) - const { result } = renderHook(() => useStandardPlugins()) + const { result } = renderHook(() => useRemotePluginsConfig()) await waitFor(() => { expect(result.current.status).not.toBe("pending") }) @@ -30,7 +38,7 @@ describe("useStandardPlugins", () => { it("handles fetch returning empty plugins array", async () => { fetchMock.mockResponseOnce("[]") - const { result } = renderHook(() => useStandardPlugins()) + const { result } = renderHook(() => useRemotePluginsConfig()) await waitFor(() => { expect(result.current.status).not.toBe("pending") }) diff --git a/v3/src/hooks/use-remote-plugins-config.ts b/v3/src/hooks/use-remote-plugins-config.ts new file mode 100644 index 0000000000..01041e07e4 --- /dev/null +++ b/v3/src/hooks/use-remote-plugins-config.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react" +import { useMemo } from "use-memo-one" +import { PluginMenuConfig } from "../components/tool-shelf/plugin-config-types" +import { urlParams } from "../utilities/url-params" + +export function useRemotePluginsConfig() { + const morePluginsParam = urlParams.morePlugins + const morePluginsUrl = useMemo(() => { + return typeof morePluginsParam === "string" ? morePluginsParam : undefined + }, [morePluginsParam]) + const [status, setStatus] = useState<"initial" | "pending" | "complete" | "error">("initial") + const [plugins, setPlugins] = useState([]) + + useEffect(() => { + async function retrievePluginMenuConfig(url: string) { + try { + setStatus("pending") + const response = await fetch(url) + 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? + if (morePluginsUrl) { + retrievePluginMenuConfig(morePluginsUrl) + } + }, [morePluginsUrl]) + + return { status, plugins } +} diff --git a/v3/src/hooks/use-standard-plugins.ts b/v3/src/hooks/use-standard-plugins.ts deleted file mode 100644 index eee345c266..0000000000 --- a/v3/src/hooks/use-standard-plugins.ts +++ /dev/null @@ -1,44 +0,0 @@ -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 } -}