Merge branch 'feat/auto-mobile-boards' into feat/homarr-assistant

This commit is contained in:
Thomas Camlong committed 2026-08-11 16:59:12 +02:00
commit e2dd68fdbc
36 files changed
+1421 -642

No files matched your search

+27 -26
View File
@@ -1,4 +1,4 @@
import { and, like } from "@homarr/db";
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
import { icons } from "@homarr/db/schema";
import { iconsFindSchema } from "@homarr/validation/icons";
@@ -15,43 +15,44 @@ export const iconsRouter = createTRPCRouter({
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? "";
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
let whereCondition = undefined;
if (term.length > 0) {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
const term = normalizeImageName(input.searchText ?? "");
const [repositories, countIcons] = await Promise.all([
ctx.db.query.iconRepositories.findMany({
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
with: {
icons: {
columns: { id: true, name: true, url: true },
where: whereCondition,
orderBy: (table, { asc, sql }) => {
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
return [svgFirst, nameAsc];
}
const tier = sql`CASE
WHEN LOWER(${table.name}) = ${term} THEN 0
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
ELSE 3
END`;
return [tier, svgFirst, nameAsc];
},
limit: input.limitPerGroup,
orderBy: (table, { asc, sql }) => [
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
asc(table.name),
],
limit: term.length === 0 ? input.limitPerGroup : undefined,
},
},
}),
ctx.db.$count(icons),
]);
const matchedRepositories = repositories.map((repository) => ({
...repository,
icons: repository.icons
.flatMap((icon) => {
const rank = getImageMatchRank(term, icon.name || icon.url);
return rank === null ? [] : [{ icon, rank }];
})
.toSorted((a, b) =>
a.rank !== b.rank
? a.rank - b.rank
: Number(b.icon.name.toLowerCase().endsWith(".svg")) -
Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
)
.slice(0, input.limitPerGroup)
.map(({ icon }) => icon),
}));
return {
icons: term.length > 0 ? repositories.filter((repository) => repository.icons.length > 0) : repositories,
icons:
term.length === 0 ? repositories : matchedRepositories.filter((repository) => repository.icons.length > 0),
countIcons,
};
}),
@@ -74,4 +74,40 @@ describe("findIcons", () => {
],
});
});
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
"matches normalized search %s before applying the group limit",
async (searchText) => {
const db = createDb();
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
await db.insert(icons).values([
{
id: "irrelevant",
name: "000-unrelated.svg",
url: "https://example.com/000-unrelated.svg",
checksum: "irrelevant",
iconRepositoryId: "repository",
},
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
checksum: "home-assistant",
iconRepositoryId: "repository",
},
]);
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
expect(result.icons[0]?.icons).toEqual([
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
},
]);
},
);
});
@@ -113,17 +113,22 @@ async function setup(
describe("Custom JSX v2 board router", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("allows public queries only through the definition placed on an accessible board", async () => {
test("loads placed widgets and public queries for anonymous visitors on public boards", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await expect(caller.getData({ itemId })).resolves.toMatchObject({
template: definition.template,
data: { "load-status": { value: 42 } },
status: { "load-status": { ok: true } },
});
await expect(caller.queryRequest({ itemId, requestId: "status", params: { name: "homarr" } })).resolves.toEqual(
expect.objectContaining({ ok: true, data: { value: 42 } }),
);
await expect(caller.queryRequest({ itemId, requestId: "missing", params: {} })).rejects.toMatchObject({
code: "NOT_FOUND",
});
expect(mocks.executeRequest).toHaveBeenCalledOnce();
expect(mocks.executeRequest).toHaveBeenCalledTimes(2);
});
test("uses current defaults when an updated option no longer accepts the stored value", async () => {
@@ -150,6 +155,7 @@ describe("Custom JSX v2 board router", () => {
await expect(
anonymous.queryRequest({ itemId: privateSetup.itemId, requestId: "status", params: { name: "homarr" } }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(anonymous.getData({ itemId: privateSetup.itemId })).rejects.toMatchObject({ code: "NOT_FOUND" });
const enabledSetup = await setup();
await enabledSetup.db
+31
View File
@@ -28,3 +28,34 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
return best;
}, null);
};
export const normalizeImageName = (value: string) => {
const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
return withoutExtension
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/&/g, " and ")
.replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
.trim()
.toLowerCase();
};
export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
const candidate = normalizeImageName(candidateValue);
if (!candidate) return null;
const compactSearch = normalizedSearch.replaceAll(" ", "");
const compactCandidate = candidate.replaceAll(" ", "");
if (candidate === normalizedSearch) return 0;
if (compactCandidate === compactSearch) return 1;
if (candidate.startsWith(`${normalizedSearch} `)) return 2;
const searchTokens = normalizedSearch.split(" ");
const candidateTokens = candidate.split(" ");
if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
if (candidate.includes(normalizedSearch)) return 5;
return null;
};
+25 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { bestMatch, capitalize } from "../string";
import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string";
const capitalizeTestCases = [
["hello", "Hello"],
@@ -54,3 +54,27 @@ describe("bestMatch should find the best match in an array of options", () => {
expect(result).toBeNull();
});
});
describe("image name matching", () => {
test.each([
["home-assistant.svg", "home assistant"],
["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
["jelly.fin.webp", "jelly fin"],
["Caf\u00e9.svg", "cafe"],
["\u6771\u4eac.svg", "\u6771\u4eac"],
["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
])("normalizes %s", (value, expected) => {
expect(normalizeImageName(value)).toBe(expected);
});
test.each([
["homeassistant", "home-assistant.svg", 1],
["home assistant", "home_assistant.png", 0],
["home assistant", "Home Assistant.webp", 0],
["\u6771\u4eac", "\u6771\u4eac.svg", 0],
["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
["home assistant", "unrelated.svg", null],
])("ranks %s against %s", (search, candidate, expected) => {
expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
});
});
@@ -1,3 +1,89 @@
[data-combobox-selected="true"] .iconCard {
border-color: var(--mantine-primary-color-6);
.dropdown {
min-width: min(32rem, calc(100vw - 2rem));
}
.options {
max-height: min(28rem, 60vh);
overflow-y: auto;
overscroll-behavior: contain;
}
.iconGrid {
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
}
.iconOption {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
height: 3.25rem;
padding: var(--mantine-spacing-xs);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.iconOption:hover,
.iconOption[data-combobox-selected="true"] {
border-color: var(--mantine-primary-color-6);
background: var(--mantine-primary-color-light);
}
.iconOption:active {
transform: scale(0.96);
}
.formatBadge,
.sectionBadge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-xs);
background: var(--mantine-color-red-9);
color: var(--mantine-color-white);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
}
.formatBadge {
position: absolute;
top: 0.2rem;
right: 0.2rem;
padding: 0.15rem 0.2rem;
}
.sectionBadge {
padding: 0.2rem 0.3rem;
}
.urlPreview {
background: var(--mantine-color-default-hover);
}
.previewFrame {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
flex: 0 0 3rem;
}
.previewLoader {
position: absolute;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.iconOption {
transition: none;
}
}
@@ -4,31 +4,84 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
import { MantineProvider } from "@mantine/core";
import type { Root } from "react-dom/client";
import type { IconGroup } from "./icon-picker.utils";
const { findIconsQuery } = vi.hoisted(() => ({
findIconsQuery: vi.fn((_query: { searchText: string }) => ({
data: { icons: [], countIcons: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
findIconsQuery: vi.fn(),
refetch: vi.fn(),
uploadSelection: vi.fn(),
sessionPermissions: { current: [] as string[] },
}));
interface QueryData {
icons: IconGroup[];
countIcons: number;
}
const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
let queryResult: {
data?: QueryData;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
};
vi.mock("@homarr/api/client", () => ({
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } },
clientApi: {
icon: {
findIcons: {
useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
findIconsQuery(input, options);
return { ...queryResult, refetch };
},
},
},
},
}));
vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: [] } } }),
useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
}));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
useScopedI18n: () => (key: string, params?: Record<string, string>) =>
params ? `${key}:${Object.values(params).join(",")}` : key,
}));
vi.mock("../upload-media/upload-media", () => ({
UploadMedia: ({
children,
onSuccess,
}: {
children: (props: object) => unknown;
onSuccess: (medias: unknown) => void;
}) =>
children({
loading: false,
onClick: () => {
uploadSelection();
onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
},
}),
}));
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
import { IconPicker } from "./icon-picker";
const directIconUrl = "https://cdn.example.com/shopify.svg";
const directImageUrl = "https://cdn.example.com/shopify.svg";
const iconGroups = [
{
id: "remote",
slug: "dashboard-icons",
icons: [
{ id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
{ id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
],
},
{
id: "local",
slug: "local",
icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
},
];
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -37,6 +90,18 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
input.dispatchEvent(new Event("input", { bubbles: true }));
};
const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
};
const focusInput = async (input: HTMLInputElement) => {
await act(async () => input.focus());
};
const advanceDebounce = async () => {
await act(async () => vi.advanceTimersByTimeAsync(300));
};
describe("IconPicker", () => {
let container: HTMLDivElement;
let root: Root;
@@ -45,9 +110,9 @@ describe("IconPicker", () => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
value: (media: string) => ({
matches: false,
media: query,
media,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
@@ -56,54 +121,186 @@ describe("IconPicker", () => {
dispatchEvent: () => false,
}),
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
});
beforeEach(() => {
vi.useFakeTimers();
findIconsQuery.mockClear();
refetch.mockClear();
uploadSelection.mockClear();
sessionPermissions.current = [];
queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
});
afterEach(() => {
root.unmount();
act(() => root.unmount());
container.remove();
document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
vi.useRealTimers();
});
const renderPicker = async (onChange: (value: string) => void) => {
const renderPicker = async (
onChange: (value: string) => void,
props: Partial<React.ComponentProps<typeof IconPicker>> = {},
) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange })));
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
});
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 () => {
test("clears an existing value without restoring it", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
const input = await renderPicker(onChange, { value: directImageUrl });
await act(async () => {
setInputValue(input, directIconUrl);
await new Promise((resolve) => setTimeout(resolve, 150));
});
await act(async () => setInputValue(input, ""));
expect(onChange).toHaveBeenCalledWith(directIconUrl);
expect(input.value).toBe(directIconUrl);
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
expect(onChange).toHaveBeenCalledWith("");
expect(input.value).toBe("");
});
test("keeps icon names in repository search mode", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
test.each([{ metaKey: true }, { ctrlKey: true }])(
"clears the entire value with a line-delete shortcut",
async (modifier) => {
const onChange = vi.fn();
const input = await renderPicker(onChange, { value: directImageUrl });
await act(async () => {
setInputValue(input, "shopify");
await new Promise((resolve) => setTimeout(resolve, 150));
});
await act(async () => pressKey(input, "Backspace", modifier));
expect(onChange).not.toHaveBeenCalled();
expect(input.value).toBe("shopify");
expect(onChange).toHaveBeenCalledWith("");
expect(input.value).toBe("");
},
);
test("debounces repository searches", async () => {
const input = await renderPicker(vi.fn());
findIconsQuery.mockClear();
await act(async () => setInputValue(input, "shopify"));
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
});
test("pre-searches the suggested app name when opened", async () => {
const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
await focusInput(input);
expect(input.value).toBe("Jellyfin");
await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
});
test("shows local images before SVG and raster library images", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const input = await renderPicker(vi.fn());
await focusInput(input);
const content = document.body.textContent ?? "";
expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
});
test("selects a library image with a pointer", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const onChange = vi.fn();
const input = await renderPicker(onChange);
await focusInput(input);
const option = document.body.querySelector<HTMLElement>(
'[data-combobox-option][value="/api/user-medias/local-icon"]',
);
if (!option) throw new Error("Local image option was not rendered");
await act(async () => option.click());
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
expect(input.value).toBe("/api/user-medias/local-icon");
});
test("selects a library image with the keyboard", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const onChange = vi.fn();
const input = await renderPicker(onChange);
await focusInput(input);
await act(async () => pressKey(input, "ArrowDown"));
await act(async () => pressKey(input, "Enter"));
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
});
test("keeps a direct URL visible and reports a successful preview", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
await act(async () => setInputValue(input, directImageUrl));
expect(onChange).toHaveBeenCalledWith(directImageUrl);
expect(input.value).toBe(directImageUrl);
expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
expect(document.body.textContent).toContain("iconPicker.urlLoading");
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
if (!preview) throw new Error("Direct URL preview was not rendered");
await act(async () => preview.dispatchEvent(new Event("load")));
expect(document.body.textContent).toContain("iconPicker.urlReady");
});
test("keeps a failed direct URL editable and shows an error", async () => {
const input = await renderPicker(vi.fn());
await act(async () => setInputValue(input, "https://invalid.example/image.png"));
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
if (!preview) throw new Error("Direct URL preview was not rendered");
await act(async () => preview.dispatchEvent(new Event("error")));
expect(input.value).toBe("https://invalid.example/image.png");
expect(document.body.textContent).toContain("iconPicker.urlError");
});
test("restores the committed value when an unfinished search is cancelled", async () => {
const input = await renderPicker(vi.fn(), { value: directImageUrl });
await act(async () => setInputValue(input, "unfinished search"));
await act(async () => pressKey(input, "Escape"));
expect(input.value).toBe(directImageUrl);
});
test("offers retry when the repository search fails", async () => {
queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
const input = await renderPicker(vi.fn());
await focusInput(input);
const retry = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "action.tryAgain",
);
if (!retry) throw new Error("Retry button was not rendered");
await act(async () => retry.click());
expect(refetch).toHaveBeenCalledOnce();
});
test("selects an uploaded local image when the user has permission", async () => {
sessionPermissions.current = ["media-upload"];
const onChange = vi.fn();
await renderPicker(onChange);
const upload = container.querySelector<HTMLButtonElement>('[aria-label="iconPicker.uploadImage"]');
if (!upload) throw new Error("Upload button was not rendered");
await act(async () => upload.click());
expect(uploadSelection).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
});
});
@@ -1,27 +1,36 @@
"use client";
import type { FocusEventHandler } from "react";
import { startTransition, useEffect, useState } from "react";
import type { FocusEventHandler, KeyboardEventHandler } from "react";
import { startTransition, useEffect, useMemo, useState } from "react";
import {
ActionIcon,
Box,
Button,
Card,
CloseButton,
Combobox,
Flex,
Group,
Image,
Indicator,
InputBase,
Loader,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
UnstyledButton,
ThemeIcon,
Tooltip,
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import {
IconCheck,
IconCloud,
IconMoodEmpty,
IconPhoto,
IconPhotoOff,
IconSearchOff,
IconUpload,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -29,6 +38,7 @@ import { supportedLanguages } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
import classes from "./icon-picker.module.css";
interface IconPickerProps {
@@ -39,9 +49,12 @@ interface IconPickerProps {
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
suggestedSearch?: string | null;
withAsterisk?: boolean;
}
type PreviewState = "idle" | "loading" | "ready" | "error";
export const IconPicker = ({
value: propsValue,
onChange,
@@ -51,188 +64,403 @@ export const IconPicker = ({
withAsterisk = true,
label,
placeholder,
suggestedSearch,
}: IconPickerProps) => {
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
const [query, setQuery] = useState("");
useEffect(() => {
setQuery("");
}, [value]);
const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" });
const [draft, setDraft] = useState(propsValue ?? "");
const [hasEdited, setHasEdited] = useState(false);
const [keyboardIndex, setKeyboardIndex] = useState(-1);
const [previewState, setPreviewState] = useState<PreviewState>("idle");
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
const [debouncedDraft] = useDebouncedValue(draft, 250);
const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : "";
const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
const isCommittedValueDraft = debouncedDraft === (value ?? "");
const searchText = directUrl !== null || isCommittedValueDraft ? "" : debouncedDraft.trim();
const canUpload = session?.user.permissions.includes("media-upload") ?? false;
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText },
{ placeholderData: (prev) => prev },
const query = clientApi.icon.findIcons.useQuery(
{ searchText, limitPerGroup: 24 },
{ enabled: directUrl === null, placeholderData: (previous) => previous },
);
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
});
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0;
const groups = data?.icons.map((group) => {
const options = group.icons.map((item) => (
<Combobox.Option
key={item.id}
value={item.url}
p={0}
h="calc(2*var(--mantine-spacing-sm)+25px)"
w="calc(2*var(--mantine-spacing-sm)+25px)"
>
<UnstyledButton
onClick={() => {
startTransition(() => {
setValue(item.url);
setQuery("");
combobox.closeDropdown();
});
}}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} />
</Box>
</Card>
</Indicator>
</UnstyledButton>
</Combobox.Option>
));
useEffect(() => {
setDraft(value ?? "");
setHasEdited(false);
}, [value]);
return (
<Paper p="xs" key={group.slug} pt={2}>
<Text mb={8} size="sm" fw="bold">
{group.slug}
</Text>
<Flex gap={8} wrap={"wrap"}>
{options}
</Flex>
</Paper>
);
});
useEffect(() => {
setPreviewState(directUrl ? "loading" : "idle");
}, [directUrl]);
const inputValue = query || value || "";
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const sections = useMemo(
() => arrangeIconPickerSections(query.data?.icons ?? [], searchText),
[query.data?.icons, searchText],
);
const orderedOptions = useMemo(
() => [...sections.local, ...sections.svg, ...sections.other],
[sections.local, sections.svg, sections.other],
);
const totalOptions = sections.local.length + sections.svg.length + sections.other.length;
const renderDropdown = () => {
if (isError) {
const commitValue = (nextValue: string) => {
startTransition(() => {
setValue(nextValue);
setDraft(nextValue);
setHasEdited(false);
setKeyboardIndex(-1);
});
};
const clearValue = () => {
commitValue("");
combobox.openDropdown("keyboard");
};
const openWithSuggestedSearch = () => {
if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
setDraft(suggestedSearch.trim());
}
combobox.openDropdown();
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
event.preventDefault();
clearValue();
return;
}
if (event.key === "Escape") {
setDraft(value ?? "");
setHasEdited(false);
combobox.closeDropdown("keyboard");
return;
}
if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
combobox.openDropdown("keyboard");
setKeyboardIndex((current) => {
const next =
event.key === "ArrowDown"
? current >= orderedOptions.length - 1
? 0
: current + 1
: current <= 0
? orderedOptions.length - 1
: current - 1;
combobox.selectOption(next);
return next;
});
return;
}
if (event.key === "Enter") {
if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
event.preventDefault();
commitValue(orderedOptions[keyboardIndex].url);
combobox.closeDropdown("keyboard");
} else if (isImageSource(draft)) {
event.preventDefault();
commitValue(draft.trim());
combobox.closeDropdown("keyboard");
}
}
};
const renderIconOptions = (icons: typeof sections.local, offset: number) => (
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6}>
{icons.map((icon, index) => (
<Tooltip key={icon.id} label={`${icon.name} · ${icon.repositorySlug}`} openDelay={500}>
<Combobox.Option
value={icon.url}
aria-label={icon.name}
active={icon.url === value || keyboardIndex === offset + index}
className={classes.iconOption}
>
<Image src={icon.url} alt="" w={30} h={30} fit="contain" fallbackSrc="/logo/logo.png" />
{isSvgImage(icon.url) && <span className={classes.formatBadge}>SVG</span>}
</Combobox.Option>
</Tooltip>
))}
</SimpleGrid>
);
const renderSearchResults = () => {
if (query.isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
<PickerMessage
icon={<IconSearchOff size={22} />}
title={tCommon("iconPicker.error")}
action={
<Button size="xs" variant="default" onClick={() => query.refetch()}>
{tCommon("action.tryAgain")}
</Button>
}
/>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
if (query.isLoading) {
return (
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6} p="xs">
{Array.from({ length: 12 }, (_, index) => (
<Skeleton key={index} height={52} radius="md" />
))}
</SimpleGrid>
);
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
if (totalOptions === 0) {
return (
<PickerMessage
icon={<IconMoodEmpty size={22} />}
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
/>
);
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
<Stack gap="sm" p="xs">
{sections.local.length > 0 && (
<PickerSection icon={<IconPhoto size={15} />} label={tCommon("iconPicker.localImages")}>
{renderIconOptions(sections.local, 0)}
</PickerSection>
)}
{sections.svg.length > 0 && (
<PickerSection label={tCommon("iconPicker.svgIcons")} badge="SVG">
{renderIconOptions(sections.svg, sections.local.length)}
</PickerSection>
)}
{sections.other.length > 0 && (
<PickerSection icon={<IconCloud size={15} />} label={tCommon("iconPicker.otherImages")}>
{renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
</PickerSection>
)}
</Stack>
);
};
const leftSection = (() => {
if (directUrl && previewState === "loading") return <Loader size={15} />;
if (directUrl && previewState === "error") return <IconPhotoOff size={17} color="var(--mantine-color-red-6)" />;
if (shouldShowPreview(value)) {
return (
<Image
src={value}
alt=""
w={22}
h={22}
fit="contain"
onLoad={() => directUrl && setPreviewState("ready")}
onError={() => directUrl && setPreviewState("error")}
/>
);
}
return <IconPhoto size={17} stroke={1.5} />;
})();
const placeholderText =
placeholder ??
(query.data
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
: tCommon("iconPicker.headerLoading"));
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={leftSection}
value={inputValue}
onChange={(event) => {
const nextValue = event.currentTarget.value;
const trimmedValue = nextValue.trim();
setQuery(nextValue);
if (isDirectIconUrl(trimmedValue)) {
setValue(trimmedValue);
combobox.closeDropdown();
} else {
combobox.openDropdown();
}
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
onFocus?.(event);
combobox.openDropdown();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setQuery("");
}}
rightSectionPointerEvents="none"
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
placeholder={placeholderText}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
setValue(first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
<Combobox
store={combobox}
withinPortal
resetSelectionOnOptionHover
onOptionSubmit={(url) => {
commitValue(url);
combobox.closeDropdown();
}}
>
<Combobox.Target withExpandedAttribute>
<InputBase
flex={1}
leftSection={leftSection}
rightSection={
<Group gap={2} wrap="nowrap">
{canUpload && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
commitValue(first.url);
combobox.closeDropdown();
}}
>
{({ onClick, loading }) => (
<Tooltip label={tCommon("iconPicker.uploadImage")}>
<ActionIcon
onClick={(event) => {
event.stopPropagation();
onClick();
}}
loading={loading}
size="sm"
variant="subtle"
aria-label={tCommon("iconPicker.uploadImage")}
>
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
</Tooltip>
)}
</UploadMedia>
)}
</UploadMedia>
)}
</Group>
{(draft || value) && (
<CloseButton
size="sm"
aria-label={tCommon("iconPicker.clear")}
onMouseDown={(event) => event.preventDefault()}
onClick={clearValue}
/>
)}
{query.isFetching && !directUrl ? <Loader size={14} /> : <Combobox.Chevron />}
</Group>
}
rightSectionWidth={canUpload ? 86 : 56}
rightSectionPointerEvents="all"
value={draft}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setDraft(nextValue);
setHasEdited(true);
setKeyboardIndex(-1);
combobox.openDropdown("keyboard");
combobox.updateSelectedOptionIndex();
if (!nextValue) {
setValue("");
} else if (isImageSource(nextValue)) {
setValue(nextValue.trim());
}
}}
onKeyDown={handleKeyDown}
onClick={openWithSuggestedSearch}
onFocus={(event) => {
onFocus?.(event);
openWithSuggestedSearch();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setDraft(value ?? "");
setHasEdited(false);
}}
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
description={tCommon("iconPicker.description")}
styles={{
description: { color: "var(--mantine-color-text)" },
input: { color: "var(--mantine-color-text)" },
error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
}}
placeholder={placeholderText}
autoComplete="off"
spellCheck={false}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{renderDropdown()}
<Combobox.Dropdown className={classes.dropdown}>
<Combobox.Options className={classes.options} aria-label={tCommon("iconPicker.results")}>
{directUrl ? (
<Paper p="sm" m="xs" className={classes.urlPreview}>
<Group wrap="nowrap" align="center">
<Box className={classes.previewFrame}>
{previewState === "loading" && <Loader size={18} className={classes.previewLoader} />}
{previewState === "error" ? (
<ThemeIcon variant="light" color="red" size={40}>
<IconPhotoOff size={21} />
</ThemeIcon>
) : (
<Image
src={directUrl}
alt=""
w={40}
h={40}
fit="contain"
data-testid="direct-url-preview"
onLoad={() => setPreviewState("ready")}
onError={() => setPreviewState("error")}
/>
)}
</Box>
<Box flex={1} miw={0}>
<Text size="sm" fw={600}>
{previewState === "error"
? tCommon("iconPicker.urlError")
: previewState === "ready"
? tCommon("iconPicker.urlReady")
: tCommon("iconPicker.urlLoading")}
</Text>
<Text size="xs" truncate>
{directUrl}
</Text>
</Box>
{previewState === "ready" && (
<ThemeIcon variant="light" color="green" size="sm" aria-hidden>
<IconCheck size={14} />
</ThemeIcon>
)}
</Group>
</Paper>
) : (
renderSearchResults()
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
// See https://github.com/homarr-labs/homarr/issues/3070
interface PickerSectionProps {
label: string;
icon?: React.ReactNode;
badge?: string;
children: React.ReactNode;
}
const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
<Stack gap={6}>
<Group gap={6} px={2}>
{icon}
<Text size="xs" fw={600}>
{label}
</Text>
{badge && <span className={classes.sectionBadge}>{badge}</span>}
</Group>
{children}
</Stack>
);
interface PickerMessageProps {
icon: React.ReactNode;
title: string;
action?: React.ReactNode;
}
const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
<Stack align="center" gap="xs" p="lg" ta="center">
<ThemeIcon variant="light" color="gray" size="lg">
{icon}
</ThemeIcon>
<Text size="sm">{title}</Text>
{action}
</Stack>
);
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
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;
}
};
@@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest";
import type { IconGroup } from "./icon-picker.utils";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
const groups = [
{
id: "remote",
slug: "dashboard-icons",
icons: [
{ id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
{ id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
{ id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
],
},
{
id: "local",
slug: "local",
icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
},
] as IconGroup[];
describe("icon picker utilities", () => {
test("arranges local images before remote SVG and raster images", () => {
const result = arrangeIconPickerSections(groups, "home assistant");
expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
});
test.each([
["https://example.com/icon.png", true],
["http://localhost:3000/icon", true],
["ftp://example.com/icon.png", false],
["javascript:alert(1)", false],
["example.com/icon.png", false],
])("validates direct image URLs", (value, expected) => {
expect(isDirectImageUrl(value)).toBe(expected);
});
test("accepts internal image paths without treating search text as a source", () => {
expect(isImageSource("/api/user-medias/123")).toBe(true);
expect(isImageSource("shopify")).toBe(false);
});
test("recognizes SVG URLs with query strings case-insensitively", () => {
expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
});
});
@@ -0,0 +1,67 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
type RouterOutput = inferRouterOutputs<AppRouter>;
export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
export interface IconPickerSections {
local: PickerIcon[];
svg: PickerIcon[];
other: PickerIcon[];
}
export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
const sections: IconPickerSections = { local: [], svg: [], other: [] };
for (const group of iconGroups) {
for (const icon of group.icons) {
const pickerIcon = { ...icon, repositorySlug: group.slug };
if (group.slug === "local") {
sections.local.push(pickerIcon);
} else if (isSvgImage(icon.url)) {
sections.svg.push(pickerIcon);
} else {
sections.other.push(pickerIcon);
}
}
}
const normalizedSearch = normalizeImageName(searchTerm);
if (normalizedSearch) {
const compareMatches = (a: PickerIcon, b: PickerIcon) => {
const aRank = getImageMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
const bRank = getImageMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
return aRank - bRank || a.name.localeCompare(b.name);
};
return {
local: sections.local.toSorted(compareMatches),
svg: sections.svg.toSorted(compareMatches),
other: sections.other.toSorted(compareMatches),
};
}
return sections;
};
export const isDirectImageUrl = (value: string) => {
try {
const url = new URL(value.trim());
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
} catch {
return false;
}
};
export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
export const isSvgImage = (value: string) => {
try {
const url = new URL(value, "http://homarr.local");
return url.pathname.toLowerCase().endsWith(".svg");
} catch {
return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
}
};
-1
View File
@@ -2,7 +2,6 @@ export * from "./new-app/_app-new-form";
export * from "./new-app/_form";
export * from "./icon-picker/icon-picker";
export * from "./new-app/icon-matcher";
export * from "./upload-media/upload-media";
@@ -3,17 +3,15 @@
import type { ChangeEventHandler } from "react";
import { useEffect, useImperativeHandle, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import { useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { appManageSchema } from "@homarr/validation/app";
import { IconPicker } from "../icon-picker/icon-picker";
import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>;
@@ -81,9 +79,6 @@ export const AppForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValuesKey]);
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current;
@@ -130,29 +125,13 @@ export const AppForm = ({
}
};
// Auto-select icon based on app name with debounced search
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
{
searchText: debouncedName,
},
{
enabled: debouncedName.length > 3,
},
);
useEffect(() => {
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
if (bestMatch) {
form.setFieldValue("iconUrl", bestMatch);
}
}
}, [debouncedName, iconsData]);
const formFields = (
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Textarea
{...form.getInputProps("description")}
label={t("app.field.description.label")}
@@ -1,30 +0,0 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
type RouterOutput = inferRouterOutputs<AppRouter>;
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
const nameLower = searchTerm.toLowerCase();
const allIcons = iconGroups.flatMap((group) => group.icons);
const getIconPriority = (iconUrl: string) => {
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
if (!fileName) return -1;
const isSvg = iconUrl.endsWith(".svg");
const isExactMatch = fileName === nameLower;
if (isExactMatch) return isSvg ? 0 : 1;
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
return -1;
};
for (let priority = 0; priority <= 3; priority++) {
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
if (match) return match.url;
}
return null;
};
@@ -85,6 +85,11 @@ export class SynologyClient {
if (volumesFromV2.length > 0) {
return volumesFromV2;
}
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
if (legacyVolumesFromV2.length > 0) {
return legacyVolumesFromV2;
}
} catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId,
@@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z
export const synologyStorageV2DataSchema = z.object({
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
vol_info: z.array(synologyVolumeInfoSchema).optional(),
});
export const synologyUtilizationCpuSchema = z.object({
@@ -426,6 +426,60 @@ describe("SynologyIntegration.getSystemInfoAsync", () => {
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
});
test("uses vol_info from storage_v2 without falling back to legacy storage", async () => {
mockSuccessfulFlow({
storageV2: {
success: true,
data: {
vol_info: [
{
name: "volume_1",
used_size: "4000000000",
total_size: "10000000000",
status: "normal",
},
{
name: "volume_2",
used_size: "5000000000",
total_size: "20000000000",
status: "attention",
},
],
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toEqual([
{
deviceName: "volume_1",
used: "4000000000",
available: "6000000000",
percentage: 40,
},
{
deviceName: "volume_2",
used: "5000000000",
available: "15000000000",
percentage: 25,
},
]);
const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => {
const urlString = toUrlString(url);
return (
getApiFromUrl(urlString) === "SYNO.Core.System" &&
getMethodFromUrl(urlString) === "info" &&
getTypeFromUrl(urlString) === "storage"
);
});
expect(legacyStorageRequests).toHaveLength(0);
});
test("continues when optional tier-2 calls fail", async () => {
mockSuccessfulFlow({ skipOptional: true });
const integration = createIntegration();
+12 -2
View File
@@ -1892,8 +1892,18 @@
"here": "here",
"iconPicker": {
"label": "Icon URL",
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
"headerLoading": "Type to search icons…",
"description": "Search the library, paste an image URL, or upload a file.",
"header": "Search {countIcons} images by name",
"headerLoading": "Search images by name",
"clear": "Clear image",
"uploadImage": "Upload image",
"results": "Image search results",
"localImages": "Your images",
"svgIcons": "SVG icons",
"otherImages": "Other images",
"urlLoading": "Checking image…",
"urlReady": "Image is ready to use",
"urlError": "This image could not be loaded. Check the URL and try again.",
"noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons."
},
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import {
Accordion,
ActionIcon,
@@ -21,7 +21,6 @@ import {
Tooltip,
} from "@mantine/core";
import type { FormErrors } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks";
import {
IconAlertTriangleFilled,
IconBrandDocker,
@@ -48,7 +47,7 @@ import {
normalizeReleaseProviderIdentifier,
releaseProviderKinds,
} from "@homarr/definitions";
import { findBestIconMatch, IconPicker } from "@homarr/forms-collection";
import { IconPicker } from "@homarr/forms-collection";
import { createModal, useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -417,13 +416,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
const [loading, setLoading] = useState(false);
const [tempRepository, setTempRepository] = useState(() => ({ ...innerProps.repository }));
const [formErrors, setFormErrors] = useState<FormErrors>({});
// Allows user to not select an icon by removing the url from the input,
// will only try and get an icon if the name or identifier changes
const [autoSetIcon, setAutoSetIcon] = useState(false);
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(tempRepository.name, 800);
const handleConfirm = useCallback(() => {
setLoading(true);
@@ -448,25 +440,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
setTempRepository((prev) => ({ ...prev, ...changedValue }));
}, []);
// Auto-select icon based on identifier formatted name with debounced search
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
{
searchText: debouncedName,
},
{
enabled: autoSetIcon && (debouncedName?.length ?? 0) > 3,
},
);
useEffect(() => {
if (autoSetIcon && debouncedName && !tempRepository.iconUrl && iconsData?.icons) {
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
if (bestMatch) {
handleChange({ iconUrl: bestMatch });
}
}
}, [debouncedName, iconsData, tempRepository, handleChange, autoSetIcon]);
return (
<Stack>
<Group align="start" wrap="nowrap" grow preventGrowOverflow={false}>
@@ -498,8 +471,6 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
identifier: event.currentTarget.value,
name,
});
if (event.currentTarget.value) setAutoSetIcon(true);
}}
error={formErrors[`${innerProps.fieldPath}.identifier`]}
style={{ flex: 0.7 }}
@@ -520,11 +491,7 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<TextInput
label={tRepository("name.label")}
value={tempRepository.name ?? ""}
onChange={(event) => {
handleChange({ name: event.currentTarget.value });
if (event.currentTarget.value) setAutoSetIcon(true);
}}
onChange={(event) => handleChange({ name: event.currentTarget.value })}
error={formErrors[`${innerProps.fieldPath}.name`]}
style={{ flex: 0.3 }}
/>
@@ -533,9 +500,9 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
<IconPicker
withAsterisk={false}
value={tempRepository.iconUrl ?? ""}
suggestedSearch={tempRepository.name || tempRepository.identifier}
onChange={(url) => {
if (url === "") {
setAutoSetIcon(false);
handleChange({ iconUrl: undefined });
} else {
handleChange({ iconUrl: url });
@@ -20,22 +20,17 @@ import {
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import type { BeszelTooltipLayer } from "./tooltip";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const makeTooltipSet = (layer: BeszelTooltipLayer) => ({
percent: makeTooltipProps(formatPercent, false, layer),
gigabytes: makeTooltipProps(formatGB, true, layer),
rate: makeTooltipProps(formatByteRate, true, layer),
percentTotal: makeTooltipProps(formatPercent, true, layer),
bytesTotal: makeTooltipProps(formatStorageBytes, true, layer),
});
const tooltipSets = {
background: makeTooltipSet("background"),
modal: makeTooltipSet("modal"),
const tooltips = {
percent: makeTooltipProps(formatPercent),
gigabytes: makeTooltipProps(formatGB, true),
rate: makeTooltipProps(formatByteRate, true),
percentTotal: makeTooltipProps(formatPercent, true),
bytesTotal: makeTooltipProps(formatStorageBytes, true),
};
const ChartSkeleton = () => (
@@ -66,7 +61,6 @@ interface BeszelStatsViewProps {
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
tooltipLayer?: BeszelTooltipLayer;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
@@ -78,14 +72,11 @@ export function BeszelStatsView({
visibility,
columns,
onSwitchToHistorical,
tooltipLayer = "background",
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const tooltips = tooltipSets[tooltipLayer];
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
@@ -50,7 +50,6 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
tooltipLayer="modal"
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
+22 -87
View File
@@ -1,8 +1,6 @@
"use client";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedDate } from "../../common/locale";
@@ -46,64 +44,18 @@ export interface TooltipPayloadItem {
payload?: Record<string, unknown>;
}
interface PortalTooltipProps {
interface BeszelTooltipProps {
active?: boolean;
label?: string;
payload?: TooltipPayloadItem[];
formatter: (value: number) => string;
showTotal?: boolean;
layer: BeszelTooltipLayer;
}
export type BeszelTooltipLayer = "background" | "modal";
const MARGIN = 14;
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal, layer }: PortalTooltipProps) => {
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
const locale = useCurrentIntlLocale();
const t = useScopedI18n("common");
const mouseRef = useRef({ x: 0, y: 0 });
const [pos, setPos] = useState({ x: 0, y: 0 });
const rafRef = useRef(0);
const tooltipRef = useRef<HTMLDivElement>(null);
const handleMouse = useCallback((e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
if (!el) {
setPos({ x: mx + MARGIN, y: my });
return;
}
const { width, height } = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = mx + MARGIN;
let y = my - height / 2;
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
if (y < MARGIN) y = MARGIN;
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
setPos({ x, y });
});
}, []);
const isActive = active && (payload?.length ?? 0) > 0;
useEffect(() => {
if (!isActive) return;
document.addEventListener("mousemove", handleMouse);
return () => {
document.removeEventListener("mousemove", handleMouse);
cancelAnimationFrame(rafRef.current);
};
}, [isActive, handleMouse]);
if (!isActive) return null;
const deduped = new Map<string, TooltipPayloadItem>();
@@ -120,54 +72,37 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal, la
: label;
const total = sorted.reduce((sum, p) => sum + p.value, 0);
const portalStyle: CSSProperties = {
position: "fixed",
left: pos.x,
top: pos.y,
zIndex:
layer === "modal" ? "var(--homarr-z-index-modal-tooltip)" : "var(--homarr-z-index-widget-background-tooltip)",
pointerEvents: "none",
};
return createPortal(
<div ref={tooltipRef} style={portalStyle}>
<div style={styles.wrapper}>
<div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>{t("total")}</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
</div>,
document.body,
return (
<div style={styles.wrapper}>
<div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>{t("total")}</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const makeTooltipProps = (
formatter: (v: number) => string,
showTotal = false,
layer: BeszelTooltipLayer = "background",
) => ({
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
content: (props: any) => (
<PortalTooltipContent
<BeszelTooltipContent
active={props.active}
label={props.label != null ? String(props.label) : undefined}
payload={props.payload}
formatter={formatter}
showTotal={showTotal}
layer={layer}
/>
),
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
wrapperStyle: { pointerEvents: "none" as const },
isAnimationActive: false,
});