Compare commits

...
Author SHA1 Message Date
ajnart cc32c167fb fix: keep direct icon URLs visible 2026-08-01 23:08:01 +02:00
2 changed files with 123 additions and 8 deletions

No files matched your search

@@ -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);
});
});
@@ -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;
}
};