From 797bdd001f13dee771f9d486fcdc6251a45ed00b Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 1 Aug 2026 23:28:18 +0200 Subject: [PATCH] fix: keep direct icon URLs visible (#6504) --- .../src/icon-picker/icon-picker.spec.ts | 109 ++++++++++++++++++ .../src/icon-picker/icon-picker.tsx | 22 ++-- 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 packages/forms-collection/src/icon-picker/icon-picker.spec.ts diff --git a/packages/forms-collection/src/icon-picker/icon-picker.spec.ts b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts new file mode 100644 index 000000000..1046b341a --- /dev/null +++ b/packages/forms-collection/src/icon-picker/icon-picker.spec.ts @@ -0,0 +1,109 @@ +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { MantineProvider } from "@mantine/core"; + +import type { Root } from "react-dom/client"; + +const { findIconsQuery } = vi.hoisted(() => ({ + findIconsQuery: vi.fn((_query: { searchText: string }) => ({ + data: { icons: [], countIcons: 0 }, + isLoading: false, + isError: false, + refetch: vi.fn(), + })), +})); + +vi.mock("@homarr/api/client", () => ({ + clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } }, +})); +vi.mock("@homarr/auth/client", () => ({ + useSession: () => ({ data: { user: { permissions: [] } } }), +})); +vi.mock("@homarr/translation", () => ({ supportedLanguages: [] })); +vi.mock("@homarr/translation/client", () => ({ + useScopedI18n: () => (key: string) => key, +})); +vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null })); + +import { IconPicker } from "./icon-picker"; + +const directIconUrl = "https://cdn.example.com/shopify.svg"; + +const setInputValue = (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + if (!setter) throw new Error("Input value setter was not found"); + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); +}; + +describe("IconPicker", () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true }); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), + }); + }); + + beforeEach(() => { + findIconsQuery.mockClear(); + }); + + afterEach(() => { + root.unmount(); + container.remove(); + }); + + const renderPicker = async (onChange: (value: string) => void) => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange }))); + }); + const input = container.querySelector("input"); + if (!input) throw new Error("Icon picker input was not rendered"); + return input; + }; + + test("keeps a direct URL visible without searching the icon repositories", async () => { + const onChange = vi.fn(); + const input = await renderPicker(onChange); + + await act(async () => { + setInputValue(input, directIconUrl); + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(onChange).toHaveBeenCalledWith(directIconUrl); + expect(input.value).toBe(directIconUrl); + expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false); + }); + + test("keeps icon names in repository search mode", async () => { + const onChange = vi.fn(); + const input = await renderPicker(onChange); + + await act(async () => { + setInputValue(input, "shopify"); + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(input.value).toBe("shopify"); + expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true); + }); +}); diff --git a/packages/forms-collection/src/icon-picker/icon-picker.tsx b/packages/forms-collection/src/icon-picker/icon-picker.tsx index 4af6f1bd0..e3e31c5be 100644 --- a/packages/forms-collection/src/icon-picker/icon-picker.tsx +++ b/packages/forms-collection/src/icon-picker/icon-picker.tsx @@ -63,7 +63,7 @@ export const IconPicker = ({ const tCommon = useScopedI18n("common"); const [debouncedQuery] = useDebouncedValue(query, 100); - const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || ""; + const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : ""; const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery( { searchText }, @@ -167,15 +167,12 @@ export const IconPicker = ({ onChange={(event) => { const nextValue = event.currentTarget.value; const trimmedValue = nextValue.trim(); - if (/^https?:\/\/[^\s/]+(?:\/\S*)?$/i.test(trimmedValue)) { - startTransition(() => { - setValue(trimmedValue); - setQuery(""); - combobox.closeDropdown(); - }); + setQuery(nextValue); + if (isDirectIconUrl(trimmedValue)) { + setValue(trimmedValue); + combobox.closeDropdown(); } else { combobox.openDropdown(); - setQuery(nextValue); } }} onClick={() => combobox.openDropdown()} @@ -230,3 +227,12 @@ const shouldShowPreview = (value: string | null | undefined): value is string => if (!value) return false; return !localizationPathRegex.test(value); }; + +const isDirectIconUrl = (value: string) => { + try { + const url = new URL(value.trim()); + return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname); + } catch { + return false; + } +};