Skip to content

Commit

Permalink
Merge pull request #1388 from concord-consortium/188066180-fix-plugin…
Browse files Browse the repository at this point in the history
…-fetch-error

fix: handling of fetch errors when loading plugin config
  • Loading branch information
kswenson authored Aug 8, 2024
2 parents cf79d90 + 7a268f2 commit 069eb2c
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 58 deletions.
75 changes: 55 additions & 20 deletions v3/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions v3/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions v3/src/components/tool-shelf/plugins-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<PluginsButton/>)
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(<PluginsButton/>)
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()
})
})
})
42 changes: 10 additions & 32 deletions v3/src/components/tool-shelf/plugins-button.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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() {
Expand Down Expand Up @@ -62,17 +46,7 @@ function PluginSelection({ pluginData }: IPluginSelectionProps) {
}

export function PluginsButton() {
const [pluginData, setPluginData] = useState<PluginData[]>([])

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 (
<Menu isLazy>
Expand All @@ -85,7 +59,11 @@ export function PluginsButton() {
<ToolShelfButtonTag className="plugins" label={t("DG.ToolButtonData.pluginMenu.title")} />
</MenuButton>
<MenuList>
{pluginData.map(pd => <PluginSelection key={pd.title} pluginData={pd} />)}
{
plugins.length
? plugins.map(pd => <PluginItem key={pd.title} pluginData={pd} />)
: <MenuItem>{t("V3.ToolButtonData.pluginMenu.fetchError")}</MenuItem>
}
</MenuList>
</Menu>
)
Expand Down
40 changes: 40 additions & 0 deletions v3/src/hooks/use-standard-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -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: [] })
})

})
44 changes: 44 additions & 0 deletions v3/src/hooks/use-standard-plugins.ts
Original file line number Diff line number Diff line change
@@ -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<PluginData[]>([])

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 }
}
Loading

0 comments on commit 069eb2c

Please sign in to comment.