Compare commits

...
Author SHA1 Message Date
ajnart e29ecf748a fix(icon-picker): keep URL visible in edit mode, add SVG-first ordering
Three UX bugs in the icon picker:

1. Skeleton-forever on edit screens. The input was pre-filled with the
   full CDN URL, the search query fired with that URL, and the DB only
   stores bare filenames, so the response was always empty -> 15
   skeletons, forever. Decouple the input display from the query:
   initialize search to the committed value and only fire the query
   when the user has actually typed something different.

2. Loading / error / no-results all looked identical (15 skeletons).
   Split the render into four explicit branches: isError (with retry),
   isLoading (skeletons, only on cold first load), results, no-results.

3. No relevance ranking and no SVG-first ordering. Add orderBy in the
   icons router: relevance tier (exact > prefix > substring) -> SVG
   first -> alphabetical. Patterns are bound as JS strings so the SQL
   works across sqlite / postgresql / mysql without concat operator
   differences.

Also add placeholderData: keepPreviousData so refetching during typing
doesn't flash the dropdown to skeletons, fix the upload pre-fill that
was leaking a local media URL into the search, drop the no-op
updateSelectedOptionIndex, and mark the file as a client component so
Next.js can render it from server-component routes.

Add common.iconPicker headerLoading / noResults / error keys to
en.json (other locales fall back to en until the next sync).

No ternaries in the changed files. Intended simplifications are marked
with ponytail: comments.
2026-06-22 12:41:04 +02:00
3 changed files with 107 additions and 72 deletions

No files matched your search

+25 -9
View File
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
}) })
.input(iconsFindSchema) .input(iconsFindSchema)
.query(async ({ ctx, input }) => { .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}%`)));
}
return { return {
icons: await ctx.db.query.iconRepositories.findMany({ icons: await ctx.db.query.iconRepositories.findMany({
with: { with: {
icons: { icons: {
columns: { columns: { id: true, name: true, url: true },
id: true, where: whereCondition,
name: true, orderBy: (table, { asc, sql }) => {
url: true, // ponytail: SVG first, then alphabetical; with a search term,
// prepend a relevance tier (exact > prefix > substring).
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];
}, },
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup, limit: input.limitPerGroup,
}, },
}, },
@@ -1,8 +1,11 @@
"use client";
import type { FocusEventHandler } from "react"; import type { FocusEventHandler } from "react";
import { startTransition } from "react"; import { startTransition, useEffect, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Box, Box,
Button,
Card, Card,
Combobox, Combobox,
Flex, Flex,
@@ -18,7 +21,7 @@ import {
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks"; import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react"; import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
@@ -34,7 +37,6 @@ interface IconPickerProps {
error?: string | null; error?: string | null;
onFocus?: FocusEventHandler; onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler; onBlur?: FocusEventHandler;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
withAsterisk?: boolean; withAsterisk?: boolean;
@@ -50,29 +52,30 @@ export const IconPicker = ({
label, label,
placeholder, placeholder,
}: IconPickerProps) => { }: IconPickerProps) => {
const [value, setValue] = useUncontrolled({ const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
value: propsValue, const [query, setQuery] = useState("");
onChange,
}); // ponytail: reset the typed query when the committed value changes
const [search, setSearch] = useUncontrolled({ // externally (form reset, parent re-render) so the input reverts to the URL.
value, useEffect(() => {
onChange: (value) => { setQuery("");
setValue(value); }, [value]);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const { data: session } = useSession(); const { data: session } = useSession();
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
const [debouncedSearch] = useDebouncedValue(search, 100); // ponytail: only search when the typed query differs from the committed
// value. On mount the input shows the existing URL (edit mode), which
// would never match the bare filenames in the icon table.
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX. // ponytail: not useSuspenseQuery — an above Suspense boundary would
const { data } = clientApi.icon.findIcons.useQuery({ // trigger on every keystroke and the dropdown flash is bad UX.
searchText: debouncedSearch, const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
}); { searchText },
{ placeholderData: (prev) => prev },
);
const combobox = useCombobox({ const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(), onDropdownClose: () => combobox.resetSelectedOption(),
@@ -90,25 +93,15 @@ export const IconPicker = ({
> >
<UnstyledButton <UnstyledButton
onClick={() => { onClick={() => {
const value = item.url;
startTransition(() => { startTransition(() => {
setPreviewUrl(value); setValue(item.url);
setSearch(value); setQuery("");
setValue(value);
combobox.closeDropdown(); combobox.closeDropdown();
}); });
}} }}
> >
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}> <Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card <Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
p="sm"
pos="relative"
style={{
overflow: "visible",
cursor: "pointer",
}}
className={classes.iconCard}
>
<Box w={25} h={25}> <Box w={25} h={25}>
<Image src={item.url} w={25} h={25} /> <Image src={item.url} w={25} h={25} />
</Box> </Box>
@@ -130,6 +123,48 @@ export const IconPicker = ({
); );
}); });
// ponytail: show the typed query if non-empty, else fall back to the URL.
const inputValue = query || value || "";
// ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
// ponytail: prop override -> real count -> loading text.
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const renderDropdown = () => {
if (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>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
</Stack>
);
};
return ( return (
<Combobox store={combobox} withinPortal> <Combobox store={combobox} withinPortal>
<Combobox.Target> <Combobox.Target>
@@ -137,16 +172,11 @@ export const IconPicker = ({
<InputBase <InputBase
flex={1} flex={1}
rightSection={<Combobox.Chevron />} rightSection={<Combobox.Chevron />}
leftSection={ leftSection={leftSection}
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null value={inputValue}
}
value={search}
onChange={(event) => { onChange={(event) => {
combobox.openDropdown(); combobox.openDropdown();
combobox.updateSelectedOptionIndex(); setQuery(event.currentTarget.value);
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
}} }}
onClick={() => combobox.openDropdown()} onClick={() => combobox.openDropdown()}
onFocus={(event) => { onFocus={(event) => {
@@ -156,25 +186,21 @@ export const IconPicker = ({
onBlur={(event) => { onBlur={(event) => {
onBlur?.(event); onBlur?.(event);
combobox.closeDropdown(); combobox.closeDropdown();
setPreviewUrl(value); setQuery("");
setSearch(value || "");
}} }}
rightSectionPointerEvents="none" rightSectionPointerEvents="none"
withAsterisk={withAsterisk} withAsterisk={withAsterisk}
error={error} error={error}
label={label ?? tCommon("iconPicker.label")} label={label ?? tCommon("iconPicker.label")}
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })} placeholder={placeholderText}
/> />
{session?.user.permissions.includes("media-upload") && ( {session?.user.permissions.includes("media-upload") && (
<UploadMedia <UploadMedia
onSuccess={(medias) => { onSuccess={(medias) => {
const first = medias.at(0); const first = medias.at(0);
if (!first) return; if (!first) return;
startTransition(() => { startTransition(() => {
setValue(first.url); setValue(first.url);
setPreviewUrl(first.url);
setSearch(first.url);
}); });
}} }}
> >
@@ -190,17 +216,7 @@ export const IconPicker = ({
<Combobox.Dropdown> <Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}> <Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? ( {renderDropdown()}
<Stack gap={4}>{groups}</Stack>
) : (
Array(15)
.fill(0)
.map((_, index: number) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
))
)}
</Combobox.Options> </Combobox.Options>
</Combobox.Dropdown> </Combobox.Dropdown>
</Combobox> </Combobox>
@@ -209,8 +225,8 @@ export const IconPicker = ({
// This regex is used to prevent loading a preview like en or /en which would trigger a language change // 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 // See https://github.com/homarr-labs/homarr/issues/3070
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */); const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null): value is string => { const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false; if (!value) return false;
return !localizationPathRegex.test(value); return !localizationPathRegex.test(value);
}; };
+4 -1
View File
@@ -1412,7 +1412,10 @@
"here": "here", "here": "here",
"iconPicker": { "iconPicker": {
"label": "Icon URL", "label": "Icon URL",
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you." "header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
"headerLoading": "Type to search icons…",
"noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons."
}, },
"colorScheme": { "colorScheme": {
"options": { "options": {