diff --git a/docs/widgets/authoring/getting-started.md b/docs/widgets/authoring/getting-started.md index d93b01862..27fbb0570 100644 --- a/docs/widgets/authoring/getting-started.md +++ b/docs/widgets/authoring/getting-started.md @@ -85,6 +85,7 @@ To ensure cohesiveness of various widgets, the following should be used as a gui - Note that we reserve the right to decline widgets for projects that are very young (eg < ~1y) or those with a small reach (eg low GitHub stars). Again, this is in an effort to keep overall widget maintenance under control. - Widgets should be only one row of blocks - Widgets should be no more than 4 blocks wide and generally conform to the styling / design choices of other widgets +- Use `withWidgetFields(service, defaultFields)` from `utils/widget-fields` to apply default fields and cap configured fields without mutating the service. The helper limits widgets to 4 fields. - Minimize the number of API calls - Avoid the use of custom proxy unless absolutely necessary - Widgets should be 'read-only', as in they should not make write changes using the relevant tool's API. Homepage widgets are designed to surface information, not to be a (usually worse) replacement for the tool itself. diff --git a/eslint.config.mjs b/eslint.config.mjs index 929077cfb..6956cd1b5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -65,24 +65,6 @@ export default defineConfig([ allowElseIf: true, }, ], - - // Keep the pre-eslint-plugin-react-hooks v6 lint policy until rules are adopted incrementally - "react-hooks/config": "off", - "react-hooks/error-boundaries": "off", - "react-hooks/exhaustive-deps": "warn", - "react-hooks/gating": "off", - "react-hooks/globals": "off", - "react-hooks/immutability": "off", - "react-hooks/incompatible-library": "off", - "react-hooks/preserve-manual-memoization": "off", - "react-hooks/purity": "off", - "react-hooks/refs": "off", - "react-hooks/rules-of-hooks": "error", - "react-hooks/set-state-in-effect": "off", - "react-hooks/set-state-in-render": "off", - "react-hooks/static-components": "off", - "react-hooks/unsupported-syntax": "off", - "react-hooks/use-memo": "off", }, }, // Vitest tests often intentionally place imports after `vi.mock(...)` to ensure diff --git a/public/images/solisquad.svg b/public/images/solisquad.svg new file mode 100644 index 000000000..b7d6b25ae --- /dev/null +++ b/public/images/solisquad.svg @@ -0,0 +1 @@ + diff --git a/src/__tests__/pages/index.test.jsx b/src/__tests__/pages/index.test.jsx index 1d8c03240..1709be353 100644 --- a/src/__tests__/pages/index.test.jsx +++ b/src/__tests__/pages/index.test.jsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ColorContext } from "utils/contexts/color"; @@ -25,6 +25,7 @@ const { throwIn: null, validateData: [], hashData: null, + hashConfig: null, mutateHash: vi.fn(), servicesData: [], bookmarksData: [], @@ -59,9 +60,12 @@ const { const serverSideTranslations = vi.fn(async (language) => ({ _translations: language })); const logger = { error: vi.fn() }; - const useSWR = vi.fn((key) => { + const useSWR = vi.fn((key, config) => { if (key === "/api/validate") return { data: state.validateData }; - if (key === "/api/hash") return { data: state.hashData, mutate: state.mutateHash }; + if (key === "/api/hash") { + state.hashConfig = config; + return { data: state.hashData, mutate: state.mutateHash }; + } if (key === "/api/services") return { data: state.servicesData }; if (key === "/api/bookmarks") return { data: state.bookmarksData }; if (key === "/api/widgets") return { data: state.widgetsData }; @@ -172,6 +176,7 @@ describe("pages/index getStaticProps", () => { state.throwIn = null; state.validateData = []; state.hashData = null; + state.hashConfig = null; state.servicesData = []; state.bookmarksData = []; state.widgetsData = []; @@ -351,6 +356,7 @@ describe("pages/index Index routing + SWR branches", () => { } await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); + act(() => state.hashConfig.onSuccess(state.hashData)); await waitFor(() => { expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate"); @@ -379,11 +385,24 @@ describe("pages/index Index routing + SWR branches", () => { localStorage.removeItem("hash"); await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); + act(() => state.hashConfig.onSuccess(state.hashData)); await waitFor(() => { expect(localStorage.getItem("hash")).toBe("first-hash"); }); }); + + it("ignores a hash response without a hash", async () => { + state.validateData = []; + localStorage.setItem("hash", "old-hash"); + + await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, settings: { layout: {} } }); + + expect(() => act(() => state.hashConfig.onSuccess(null))).not.toThrow(); + expect(() => act(() => state.hashConfig.onSuccess({}))).not.toThrow(); + expect(localStorage.getItem("hash")).toBe("old-hash"); + expect(document.querySelector(".animate-spin")).toBeFalsy(); + }); }); describe("pages/index Home behavior", () => { @@ -419,11 +438,27 @@ describe("pages/index Home behavior", () => { fireEvent.keyDown(document.body, { key: "a" }); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3"); + expect(state.quickLaunchProps.searchString).toBe("a"); fireEvent.keyDown(document.body, { key: "Escape" }); expect(screen.getByTestId("quicklaunch")).toHaveTextContent("closed:3"); }); + it("opens search on space without seeding the query with whitespace", async () => { + await renderIndex({ + initialSettings: { title: "Homepage", layout: {} }, + settings: { title: "Homepage", layout: {}, language: "en" }, + }); + + await waitFor(() => { + expect(state.quickLaunchProps).toBeTruthy(); + }); + + fireEvent.keyDown(document.body, { key: " " }); + expect(screen.getByTestId("quicklaunch")).toHaveTextContent("open:3"); + expect(state.quickLaunchProps.searchString).toBe(""); + }); + it("renders services and bookmark groups when present", async () => { await renderIndex({ initialSettings: { title: "Homepage", layout: {} }, diff --git a/src/components/quicklaunch.jsx b/src/components/quicklaunch.jsx index f89192a66..220c6dbb2 100644 --- a/src/components/quicklaunch.jsx +++ b/src/components/quicklaunch.jsx @@ -15,6 +15,59 @@ const MOBILE_BUTTON_POSITIONS = { "bottom-right": "bottom-4 right-4", }; +function getSearchResults({ + hideVisitURL, + searchDescriptions, + searchProvider, + searchString, + searchSuggestions, + servicesAndBookmarks, + t, + url, +}) { + if (searchString.trim().length === 0) return []; + + const results = servicesAndBookmarks.flatMap((result) => { + const nameMatch = result.name.toLowerCase().includes(searchString); + const descriptionMatch = searchDescriptions && result.description?.toLowerCase().includes(searchString); + + if (!nameMatch && !descriptionMatch) return []; + return [{ ...result, ...(searchDescriptions && { priority: nameMatch ? 2 : +descriptionMatch }) }]; + }); + + if (searchDescriptions) { + results.sort((a, b) => b.priority - a.priority); + } + + if (searchProvider) { + results.push({ + href: searchProvider.url + encodeURIComponent(searchString), + name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`, + type: "search", + }); + + if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl && searchSuggestions[1]) { + results.push( + ...searchSuggestions[1].map((suggestion) => ({ + href: searchProvider.url + encodeURIComponent(suggestion), + name: suggestion, + type: "searchSuggestion", + })), + ); + } + } + + if (!hideVisitURL && url) { + results.unshift({ + href: url.toString(), + name: `${t("quicklaunch.visit")} URL`, + type: "url", + }); + } + + return results; +} + export default function QuickLaunch({ servicesAndBookmarks, searchString, setSearchString, isOpen, setSearching }) { const { t } = useTranslation(); @@ -23,7 +76,6 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea const searchField = useRef(); - const [results, setResults] = useState([]); const [currentItemIndex, setCurrentItemIndex] = useState(null); const [url, setUrl] = useState(null); const [searchSuggestions, setSearchSuggestions] = useState([]); @@ -62,7 +114,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea : null; function openCurrentItem(newWindow) { - const result = results[currentItemIndex]; + const result = results[activeItemIndex]; window.open( result.href, newWindow ? "_blank" : (result.target ?? searchProvider?.target ?? settings.target ?? "_blank"), @@ -81,6 +133,7 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea function handleSearchChange(event) { const rawSearchString = event.target.value; + setCurrentItemIndex(null); try { if (!/.+[.:].+/g.test(rawSearchString)) throw new Error(); // basic test for probably a url let urlString = rawSearchString; @@ -103,18 +156,19 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea } else if (event.key === "Enter" && results.length) { closeAndReset(); openCurrentItem(event.metaKey); - } else if (event.key === "ArrowDown" && results[currentItemIndex + 1]) { - setCurrentItemIndex(currentItemIndex + 1); + } else if (event.key === "ArrowDown" && results[activeItemIndex + 1]) { + setCurrentItemIndex(activeItemIndex + 1); event.preventDefault(); - } else if (event.key === "ArrowUp" && currentItemIndex > 0) { - setCurrentItemIndex(currentItemIndex - 1); + } else if (event.key === "ArrowUp" && activeItemIndex > 0) { + setCurrentItemIndex(activeItemIndex - 1); event.preventDefault(); } else if ( event.key === "ArrowRight" && - results[currentItemIndex] && - results[currentItemIndex].type === "searchSuggestion" + results[activeItemIndex] && + results[activeItemIndex].type === "searchSuggestion" ) { - setSearchString(results[currentItemIndex].name); + setCurrentItemIndex(null); + setSearchString(results[activeItemIndex].name); } } @@ -138,88 +192,51 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea } useEffect(() => { - const abortController = new AbortController(); - - if (searchString.trim().length === 0) setResults([]); - else { - let newResults = servicesAndBookmarks.filter((r) => { - const nameMatch = r.name.toLowerCase().includes(searchString); - let descriptionMatch; - if (searchDescriptions) { - descriptionMatch = r.description?.toLowerCase().includes(searchString); - r.priority = nameMatch ? 2 * +nameMatch : +descriptionMatch; - } - return nameMatch || descriptionMatch; - }); - - if (searchDescriptions) { - newResults = newResults.sort((a, b) => b.priority - a.priority); - } - - if (searchProvider) { - newResults.push({ - href: searchProvider.url + encodeURIComponent(searchString), - name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")}`, - type: "search", - }); - - if (searchProvider.showSearchSuggestions && searchProvider.suggestionUrl) { - if (searchString.trim() !== searchSuggestions[0]?.trim()) { - fetch( - `/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${ - searchProvider.name ?? "Custom" - }`, - { signal: abortController.signal }, - ) - .then(async (searchSuggestionResult) => { - const newSearchSuggestions = await searchSuggestionResult.json(); - - if (newSearchSuggestions) { - if (newSearchSuggestions[1].length > 4) { - newSearchSuggestions[1] = newSearchSuggestions[1].splice(0, 4); - } - setSearchSuggestions(newSearchSuggestions); - } - }) - .catch(() => { - // If there is an error, just ignore it. There just will be no search suggestions. - }); - } - - if (searchSuggestions[1]) { - newResults = newResults.concat( - searchSuggestions[1].map((suggestion) => ({ - href: searchProvider.url + encodeURIComponent(suggestion), - name: suggestion, - type: "searchSuggestion", - })), - ); - } - } - } - - if (!hideVisitURL && url) { - newResults.unshift({ - href: url.toString(), - name: `${t("quicklaunch.visit")} URL`, - type: "url", - }); - } - - setResults(newResults); - - if (newResults.length) { - setCurrentItemIndex(0); - } + if ( + searchString.trim().length === 0 || + !searchProvider?.showSearchSuggestions || + !searchProvider.suggestionUrl || + searchString.trim() === searchSuggestions[0]?.trim() + ) { + return undefined; } - return () => { - abortController.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchString, servicesAndBookmarks, searchDescriptions, hideVisitURL, searchSuggestions, searchProvider, url]); + const abortController = new AbortController(); + + fetch( + `/api/search/searchSuggestion?query=${encodeURIComponent(searchString)}&providerName=${ + searchProvider.name ?? "Custom" + }`, + { signal: abortController.signal }, + ) + .then(async (searchSuggestionResult) => { + const newSearchSuggestions = await searchSuggestionResult.json(); + + if (newSearchSuggestions) { + setCurrentItemIndex(null); + setSearchSuggestions([newSearchSuggestions[0], newSearchSuggestions[1].slice(0, 4)]); + } + }) + .catch(() => { + // If there is an error, just ignore it. There just will be no search suggestions. + }); + + return () => abortController.abort(); + }, [searchProvider, searchString, searchSuggestions]); + + const results = getSearchResults({ + hideVisitURL, + searchDescriptions, + searchProvider, + searchString, + searchSuggestions, + servicesAndBookmarks, + t, + url, + }); + + const activeItemIndex = currentItemIndex ?? (results.length ? 0 : null); - const [hidden, setHidden] = useState(true); useEffect(() => { function handleBackdropClick(event) { if (event.target?.tagName === "DIV") closeAndReset(); @@ -228,14 +245,11 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea if (isOpen) { searchField.current.focus(); document.body.addEventListener("click", handleBackdropClick); - setHidden(false); } else { - document.body.removeEventListener("click", handleBackdropClick); searchField.current.blur(); - setTimeout(() => { - setHidden(true); - }, 300); // disable on close } + + return () => document.body.removeEventListener("click", handleBackdropClick); }, [isOpen, closeAndReset]); function highlightText(text) { @@ -259,13 +273,17 @@ export default function QuickLaunch({ servicesAndBookmarks, searchString, setSea <>