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

+9 -3
View File
@@ -12,7 +12,7 @@ tags:
Icons in Homarr are automatically requested from multiple sources:
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
- [selfh.st icons](https://github.com/selfhst/icons)
- [Simple icons](https://github.com/simple-icons/simple-icons)
- [Tabler icons](https://tabler-icons.io/)
@@ -21,7 +21,7 @@ Icons in Homarr are automatically requested from multiple sources:
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
### Finding Missing Icons
@@ -31,7 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
### Uploading a local image
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
+1 -1
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png)
@@ -39,6 +39,9 @@ Action buttons can only be executed by administrators.
Each named request declares its minimum board permission. This permission does not depend on the HTTP method.
Anonymous visitors can load queries from a Custom API widget on a public board. Definitions placed only on private
boards remain unavailable to them.
Queries and actions can use GET, POST, PUT, or PATCH. DELETE is valid only for actions so it can never run
automatically while a board loads. The `kind` value controls the remaining runtime behavior.
@@ -170,7 +170,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<EditorSection id="general" title={w("generalInformation")} icon={IconSettings}>
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} autosize minRows={2} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</EditorSection>
<EditorSection id="sources" title={w("sources.title")} icon={IconApi}>
<CustomWidgetSourcesEditor form={form} definitionId={definitionId} />
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
@@ -0,0 +1,25 @@
export const observeGridDragHandle = (
container: HTMLElement,
selector: string,
bind: (element: HTMLElement | null) => void,
) => {
let observer: MutationObserver | null = null;
const bindFirstHandle = () => {
const handle = container.querySelector<HTMLElement>(selector);
if (!handle) return false;
bind(handle);
observer?.disconnect();
observer = null;
return true;
};
if (!bindFirstHandle()) {
observer = new MutationObserver(bindFirstHandle);
observer.observe(container, { childList: true, subtree: true });
}
return () => {
observer?.disconnect();
bind(null);
};
};
@@ -75,8 +75,8 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting="drag"] .board-grid-entry,
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
@@ -1,6 +1,6 @@
"use client";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren, RefObject } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
import {
createContext,
memo,
@@ -36,12 +36,7 @@ import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDro
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import {
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
LOGICAL_GRID_PITCH,
} from "~/components/board/layout";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import {
@@ -61,9 +56,11 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
import type { DndGridEntryProps } from "./grid-entry-memo";
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth";
import { observeGridDragHandle } from "./grid-drag-handle";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -103,17 +100,6 @@ interface RegisteredGrid {
element: HTMLElement;
}
interface GridInteraction {
activeId: string;
sourceGridId: string;
targetGridId: string | null;
targetPlacement: SectionGridPlacement | null;
state: TransactionalGridState<SectionGridPlacement>;
mode: "drag" | "resize";
valid: boolean;
previewRevision: number;
}
interface ActiveTransaction {
transaction: GridTransaction<SectionGridPlacement>;
label: string;
@@ -173,13 +159,6 @@ const useBoardGridEditorInteraction = () => {
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
};
const useBoardGridEditorGridInteraction = (sectionId: string) => {
const store = useBoardGridEditorInteractionStore();
const subscribe = useCallback((listener: () => void) => store.subscribeGrid(sectionId, listener), [sectionId, store]);
const getSnapshot = useCallback(() => store.getGridSnapshot(sectionId), [sectionId, store]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** One provider coordinates every root and nested grid on the board. */
export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
@@ -716,10 +695,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridInteractionEffects
cancelActiveInteraction={cancelActiveInteraction}
syncRenderedInteraction={syncRenderedInteraction}
/>
<GridInteractionEffects syncRenderedInteraction={syncRenderedInteraction} />
</DragDropProvider>
</BoardGridEditorInteractionContext.Provider>
</BoardGridEditorActionsContext.Provider>
@@ -727,27 +703,14 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
};
const GridInteractionEffects = ({
cancelActiveInteraction,
syncRenderedInteraction,
}: {
cancelActiveInteraction: () => void;
syncRenderedInteraction: (interaction: GridInteraction | null) => void;
}) => {
const interaction = useBoardGridEditorInteraction();
useLayoutEffect(() => syncRenderedInteraction(interaction), [interaction, syncRenderedInteraction]);
useEffect(() => {
if (interaction?.mode !== "resize") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
cancelActiveInteraction();
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [cancelActiveInteraction, interaction?.mode]);
return <GridCollisionSynchronizer previewRevision={interaction?.previewRevision ?? null} />;
};
@@ -775,8 +738,9 @@ export default function GridEditor({
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const interactionStore = useBoardGridEditorInteractionStore();
const gridRef = useRef<HTMLDivElement>(null);
const entryElementsRef = useRef(new Map<string, HTMLElement>());
const [entryElements] = useState(() => new Map<string, HTMLElement>());
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
const depth = getDepth(sectionId);
@@ -811,10 +775,13 @@ export default function GridEditor({
},
[droppableRef],
);
const registerEntryElement = useCallback((id: string, element: HTMLElement | null) => {
if (element) entryElementsRef.current.set(id, element);
else entryElementsRef.current.delete(id);
}, []);
const registerEntryElement = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) entryElements.set(id, element);
else entryElements.delete(id);
},
[entryElements],
);
useLayoutEffect(() => {
const element = gridRef.current;
@@ -864,7 +831,8 @@ export default function GridEditor({
maxRowCount={maxRowCount}
placements={placements}
gridRef={gridRef}
entryElementsRef={entryElementsRef}
entryElements={entryElements}
interactionStore={interactionStore}
/>
<span
data-grid-runtime="homarr-dnd-kit-v1"
@@ -876,103 +844,6 @@ export default function GridEditor({
);
}
interface GridPreviewLayerProps {
sectionId: string;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
gridRef: RefObject<HTMLDivElement | null>;
entryElementsRef: RefObject<Map<string, HTMLElement>>;
}
const GridPreviewLayer = ({
sectionId,
rowCount,
maxRowCount,
placements,
gridRef,
entryElementsRef,
}: GridPreviewLayerProps) => {
const interaction = useBoardGridEditorGridInteraction(sectionId);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const previewStateRef = useRef(createGridPreviewDomState());
const placementsRef = useRef(placements);
placementsRef.current = placements;
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
const growsDuringDrag =
interaction?.mode === "drag" &&
(interaction.targetGridId === sectionId ||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements);
const renderedRowCount =
maxRowCount === null && interaction ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount;
useLayoutEffect(() => {
previewStateRef.current = syncGridPreviewGeometry({
elements: entryElementsRef.current,
placements,
previewPlacements: previewGrid?.placements ?? null,
activeId: interaction?.activeId ?? null,
mode: interaction?.mode ?? null,
previous: previewStateRef.current,
});
const grid = gridRef.current;
if (!grid) return;
const isIntentTarget = interaction?.mode === "drag" && interaction.targetGridId === sectionId;
setOptionalAttribute(grid, "data-dnd-drop-target", isIntentTarget ? "true" : "false");
setOptionalAttribute(grid, "data-dnd-drop-valid", isIntentTarget ? String(targetPlacement !== null) : null);
setOptionalAttribute(
grid,
"data-dnd-preview-revision",
interaction?.targetGridId === sectionId ? String(interaction.previewRevision) : null,
);
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
}
}, [
entryElementsRef,
gridRef,
interaction,
placements,
previewGrid?.placements,
renderedRowCount,
sectionId,
targetPlacement,
]);
useLayoutEffect(
() => () => {
clearGridPreviewGeometry(entryElementsRef.current, placementsRef.current, previewStateRef.current);
const grid = gridRef.current;
if (!grid) return;
grid.removeAttribute("data-dnd-drop-target");
grid.removeAttribute("data-dnd-drop-valid");
grid.removeAttribute("data-dnd-preview-revision");
grid.parentElement?.style.removeProperty("--board-grid-drag-height");
},
[entryElementsRef, gridRef],
);
return targetPlacement ? (
<div
className="board-grid-placeholder"
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
data-grid-h={targetPlacement.h}
aria-hidden="true"
/>
) : null;
};
const DndGridEntryComponent = ({
sectionId,
placement,
@@ -1023,7 +894,7 @@ const DndGridEntryComponent = ({
const {
ref: draggableRef,
handleRef,
isDragging,
isDragSource,
isDropping,
} = useDraggable<GridEntryData>({
id: placement.id,
@@ -1051,15 +922,7 @@ const DndGridEntryComponent = ({
const handleSelector =
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
const bindHandle = () => handleRef(container.querySelector<HTMLElement>(handleSelector));
bindHandle();
const observer = new MutationObserver(bindHandle);
observer.observe(container, { childList: true, subtree: true });
return () => {
observer.disconnect();
handleRef(null);
};
return observeGridDragHandle(container, handleSelector, handleRef);
}, [acquireContainer, handleRef, placement.id, placement.type]);
useLayoutEffect(() => {
@@ -1097,7 +960,7 @@ const DndGridEntryComponent = ({
data-grid-w={placement.w}
data-grid-h={placement.h}
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
data-dnd-drag-source={isDragging ? "true" : undefined}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
@@ -1112,7 +975,7 @@ const DndGridEntryComponent = ({
previewResize={previewResize}
completeResize={completeResize}
cancelResize={cancelResize}
setContinuousResize={resizeOutlineController.schedule}
scheduleResizeOutline={resizeOutlineController.schedule}
/>
</div>
);
@@ -1131,7 +994,7 @@ interface GridResizeHandlesProps {
previewResize: BoardGridEditorActionsContextValue["previewResize"];
completeResize: BoardGridEditorActionsContextValue["completeResize"];
cancelResize: BoardGridEditorActionsContextValue["cancelResize"];
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
scheduleResizeOutline: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
}
const RESIZE_DIRECTIONS: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
@@ -1147,16 +1010,26 @@ const GridResizeHandles = ({
previewResize,
completeResize,
cancelResize,
setContinuousResize,
scheduleResizeOutline,
}: GridResizeHandlesProps) => {
const pointerStartRef = useRef<{
id: number;
handle: HTMLSpanElement;
x: number;
y: number;
direction: GridResizeDirection;
visualScale: { x: number; y: number };
placement: SectionGridPlacement;
} | null>(null);
const escapeListenerRef = useRef<((event: KeyboardEvent) => void) | null>(null);
useEffect(
() => () => {
const listener = escapeListenerRef.current;
if (listener) window.removeEventListener("keydown", listener, { capture: true });
},
[],
);
const start = (direction: GridResizeDirection) =>
beginResize({ activeId: placement.id, sourceGridId: sectionId, label }) && direction;
@@ -1170,6 +1043,44 @@ const GridResizeHandles = ({
minHeight: placement.minH ?? 1,
});
const removeEscapeListener = () => {
const listener = escapeListenerRef.current;
if (!listener) return;
window.removeEventListener("keydown", listener, { capture: true });
escapeListenerRef.current = null;
};
const finishResize = (pointerId: number, current: { x: number; y: number } | null, canceled: boolean) => {
const started = pointerStartRef.current;
if (!started || started.id !== pointerId) return;
pointerStartRef.current = null;
scheduleResizeOutline(null);
removeEscapeListener();
if (started.handle.hasPointerCapture(pointerId)) started.handle.releasePointerCapture(pointerId);
if (canceled || !current) {
cancelResize();
return;
}
const delta = getSnappedGridDelta({
initial: { x: started.x, y: started.y },
current,
visualScale: started.visualScale,
});
if (delta) update(started.direction, delta.x, delta.y);
completeResize();
};
const handleEscape = (event: KeyboardEvent) => {
const started = pointerStartRef.current;
if (event.key !== "Escape" || !started) return;
event.preventDefault();
event.stopPropagation();
finishResize(started.id, null, true);
};
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
if (
@@ -1191,12 +1102,15 @@ const GridResizeHandles = ({
}
pointerStartRef.current = {
id: event.pointerId,
handle: event.currentTarget,
x: event.clientX,
y: event.clientY,
direction,
visualScale,
placement: { ...placement },
};
escapeListenerRef.current = handleEscape;
window.addEventListener("keydown", handleEscape, { capture: true });
};
const handlePointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
@@ -1212,7 +1126,7 @@ const GridResizeHandles = ({
const continuousDelta = getContinuousGridDelta(input);
if (!snappedDelta || !continuousDelta) return;
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
setContinuousResize({
scheduleResizeOutline({
placement: getContinuousResizePlacement({
placement: started.placement,
direction: started.direction,
@@ -1228,22 +1142,7 @@ const GridResizeHandles = ({
};
const handlePointerEnd = (event: ReactPointerEvent<HTMLSpanElement>, canceled: boolean) => {
const started = pointerStartRef.current;
if (!started || started.id !== event.pointerId) return;
pointerStartRef.current = null;
setContinuousResize(null);
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
if (canceled) cancelResize();
else {
const delta = getSnappedGridDelta({
initial: { x: started.x, y: started.y },
current: { x: event.clientX, y: event.clientY },
visualScale: started.visualScale,
});
if (delta) update(started.direction, delta.x, delta.y);
completeResize();
}
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
};
return RESIZE_DIRECTIONS.map((direction) => (
@@ -1257,12 +1156,7 @@ const GridResizeHandles = ({
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => {
if (pointerStartRef.current?.id !== event.pointerId) return;
pointerStartRef.current = null;
setContinuousResize(null);
cancelResize();
}}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
/>
));
};
@@ -1325,11 +1219,6 @@ const getAncestorCssZoom = (element: Element): number => {
const arePointerCoordinatesEqual = (first: { x: number; y: number } | null, second: { x: number; y: number } | null) =>
first !== null && second !== null && Math.abs(first.x - second.x) <= 0.5 && Math.abs(first.y - second.y) <= 0.5;
const setOptionalAttribute = (element: HTMLElement, name: string, value: string | null) => {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
};
const getPointerTargetGrid = (
grids: Iterable<RegisteredGrid>,
source: AbstractDraggable,
@@ -6,10 +6,10 @@ export interface GridPreviewDomState {
changedIds: Set<string>;
}
interface SyncGridPreviewGeometryInput<TPlacement extends GridPlacement> {
interface SyncGridPreviewGeometryInput {
elements: ReadonlyMap<string, HTMLElement>;
placements: readonly TPlacement[];
previewPlacements: readonly TPlacement[] | null;
placements: readonly GridPlacement[];
previewPlacements: readonly GridPlacement[] | null;
activeId: string | null;
mode: "drag" | "resize" | null;
previous: GridPreviewDomState;
@@ -20,14 +20,14 @@ export const createGridPreviewDomState = (): GridPreviewDomState => ({
changedIds: new Set(),
});
export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
export const syncGridPreviewGeometry = ({
elements,
placements,
previewPlacements,
activeId,
mode,
previous,
}: SyncGridPreviewGeometryInput<TPlacement>): GridPreviewDomState => {
}: SyncGridPreviewGeometryInput): GridPreviewDomState => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
const changedIds = new Set<string>();
@@ -58,9 +58,9 @@ export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
return { activeId, changedIds };
};
export const clearGridPreviewGeometry = <TPlacement extends GridPlacement>(
export const clearGridPreviewGeometry = (
elements: ReadonlyMap<string, HTMLElement>,
placements: readonly TPlacement[],
placements: readonly GridPlacement[],
state: GridPreviewDomState,
) => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
@@ -0,0 +1,136 @@
"use client";
import type { RefObject } from "react";
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
import type { TransactionalGridState } from "./dnd";
import type { GridInteractionStore } from "./grid-interaction-store";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export interface GridInteraction {
activeId: string;
sourceGridId: string;
targetGridId: string | null;
targetPlacement: SectionGridPlacement | null;
state: TransactionalGridState<SectionGridPlacement>;
mode: "drag" | "resize";
valid: boolean;
previewRevision: number;
}
interface GridPreviewLayerProps {
sectionId: string;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
gridRef: RefObject<HTMLDivElement | null>;
entryElements: ReadonlyMap<string, HTMLElement>;
interactionStore: GridInteractionStore<GridInteraction>;
}
export const GridPreviewLayer = ({
sectionId,
rowCount,
maxRowCount,
placements,
gridRef,
entryElements,
interactionStore,
}: GridPreviewLayerProps) => {
const subscribe = useCallback(
(listener: () => void) => interactionStore.subscribeGrid(sectionId, listener),
[interactionStore, sectionId],
);
const getSnapshot = useCallback(() => interactionStore.getGridSnapshot(sectionId), [interactionStore, sectionId]);
const interaction = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const previewStateRef = useRef(createGridPreviewDomState());
const placementsRef = useRef(placements);
const isDragging = interaction?.mode === "drag";
const isTargetGrid = isDragging && interaction.targetGridId === sectionId;
let targetPlacement: SectionGridPlacement | null = null;
if (interaction?.valid && interaction.targetGridId === sectionId) {
targetPlacement = interaction.targetPlacement;
}
const growsDuringDrag =
isDragging && (isTargetGrid || (interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements);
let renderedRowCount = rowCount;
if (maxRowCount === null && interaction) {
renderedRowCount = Math.max(rowCount, previewRowCount);
if (growsDuringDrag) renderedRowCount += 1;
}
useLayoutEffect(() => {
placementsRef.current = placements;
previewStateRef.current = syncGridPreviewGeometry({
elements: entryElements,
placements,
previewPlacements: previewGrid?.placements ?? null,
activeId: interaction?.activeId ?? null,
mode: interaction?.mode ?? null,
previous: previewStateRef.current,
});
const grid = gridRef.current;
if (!grid) return;
grid.dataset.dndDropTarget = String(isTargetGrid);
if (isTargetGrid) grid.dataset.dndDropValid = String(targetPlacement !== null);
else delete grid.dataset.dndDropValid;
if (interaction?.targetGridId === sectionId) {
grid.dataset.dndPreviewRevision = String(interaction.previewRevision);
} else {
delete grid.dataset.dndPreviewRevision;
}
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
}
}, [
entryElements,
gridRef,
interaction,
isTargetGrid,
placements,
previewGrid?.placements,
renderedRowCount,
sectionId,
targetPlacement,
]);
useLayoutEffect(
() => () => {
clearGridPreviewGeometry(entryElements, placementsRef.current, previewStateRef.current);
const grid = gridRef.current;
if (!grid) return;
grid.removeAttribute("data-dnd-drop-target");
grid.removeAttribute("data-dnd-drop-valid");
grid.removeAttribute("data-dnd-preview-revision");
grid.parentElement?.style.removeProperty("--board-grid-drag-height");
},
[entryElements, gridRef],
);
if (!targetPlacement) return null;
return (
<div
className="board-grid-placeholder"
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
data-grid-h={targetPlacement.h}
aria-hidden="true"
/>
);
};
@@ -1,17 +1,17 @@
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
interface ResizeOutlinePreview<TPlacement extends GridPlacement> {
placement: TPlacement;
interface ResizeOutlinePreview {
placement: GridPlacement;
valid: boolean;
}
export const createGridResizeOutlineController = <TPlacement extends GridPlacement>(
export const createGridResizeOutlineController = (
getShell: () => HTMLElement | null,
getControlledPlacement: () => TPlacement,
getControlledPlacement: () => GridPlacement,
) => {
let frame = 0;
let pending: ResizeOutlinePreview<TPlacement> | null = null;
let pending: ResizeOutlinePreview | null = null;
const remove = () => {
getShell()?.querySelector(":scope > [data-grid-resize-outline]")?.remove();
@@ -37,7 +37,7 @@ export const createGridResizeOutlineController = <TPlacement extends GridPlaceme
};
return {
schedule(preview: ResizeOutlinePreview<TPlacement> | null) {
schedule(preview: ResizeOutlinePreview | null) {
pending = preview;
if (!preview) {
window.cancelAnimationFrame(frame);
@@ -0,0 +1,33 @@
import { describe, expect, test, vi } from "vitest";
import { observeGridDragHandle } from "../grid-drag-handle";
describe("grid drag handle", () => {
test("disconnects the observer after binding the first handle", async () => {
const container = document.createElement("div");
document.body.append(container);
const bind = vi.fn();
const cleanup = observeGridDragHandle(container, "[data-drag-handle]", bind);
expect(bind).not.toHaveBeenCalled();
container.append(document.createElement("span"));
await Promise.resolve();
expect(bind).not.toHaveBeenCalled();
const handle = document.createElement("button");
handle.dataset.dragHandle = "";
container.append(handle);
await vi.waitFor(() => expect(bind).toHaveBeenCalledOnce());
expect(bind).toHaveBeenLastCalledWith(handle);
handle.append(document.createElement("span"));
container.prepend(document.createElement("button"));
await Promise.resolve();
expect(bind).toHaveBeenCalledOnce();
cleanup();
expect(bind).toHaveBeenLastCalledWith(null);
container.remove();
});
});
@@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { DndGridEntryProps } from "../grid-entry-memo";
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
import { createGridInteractionStore } from "../grid-interaction-store";
import type { GridInteraction } from "../grid-preview-layer";
import { GridPreviewLayer } from "../grid-preview-layer";
describe("grid entry memoization", () => {
let container: HTMLDivElement;
@@ -76,8 +78,13 @@ describe("grid entry memoization", () => {
expect(renders).toBe(101);
});
test("keeps 100 entry and widget renders frozen while preview geometry changes", () => {
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
const elements = new Map<string, HTMLElement>();
const interactionStore = createGridInteractionStore<GridInteraction>();
const gridRef = { current: null as HTMLDivElement | null };
const setGridRef = (element: HTMLDivElement | null) => {
gridRef.current = element;
};
const registerElement = (id: string, element: HTMLElement | null) => {
if (element) elements.set(id, element);
else elements.delete(id);
@@ -87,21 +94,50 @@ describe("grid entry memoization", () => {
registerElement,
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
}));
renderBoard(entries);
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
act(() =>
root.render(
createElement(
"div",
{ "data-section-id": "root" },
createElement(
"div",
{ ref: setGridRef },
nextEntries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })),
createElement(GridPreviewLayer, {
sectionId: "root",
rowCount: 10,
maxRowCount: null,
placements: nextEntries.map((entry) => entry.placement),
gridRef,
entryElements: elements,
interactionStore,
}),
),
),
),
);
};
renderPreviewBoard(entries);
expect(renders).toBe(100);
expect(widgetRenders).toBe(100);
let previewState = createGridPreviewDomState();
for (let revision = 1; revision <= 20; revision += 1) {
previewState = syncGridPreviewGeometry({
elements,
placements: entries.map((entry) => entry.placement),
previewPlacements: entries.map((entry, index) =>
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
),
activeId: "item-0",
mode: "drag",
previous: previewState,
const previewPlacements = entries.map((entry, index) =>
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
);
act(() => {
interactionStore.publish({
activeId: "item-0",
sourceGridId: "root",
targetGridId: "root",
targetPlacement: previewPlacements[0] ?? null,
state: { grids: [{ id: "root", columnCount: 12, maxRowCount: null, placements: previewPlacements }] },
mode: "drag",
valid: true,
previewRevision: revision,
});
});
}
@@ -109,13 +145,16 @@ describe("grid entry memoization", () => {
expect(widgetRenders).toBe(100);
expect(elements.get("item-42")?.getAttribute("data-grid-preview")).toBe("true");
renderBoard(
act(() => interactionStore.publish(null));
renderPreviewBoard(
entries.map((entry, index) =>
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 20 } } : entry,
),
);
expect(renders).toBe(101);
expect(widgetRenders).toBe(101);
expect(gridRef.current?.dataset.dndDropTarget).toBe("false");
expect(gridRef.current?.hasAttribute("data-dnd-preview-revision")).toBe(false);
});
test.each([
+2
View File
@@ -843,6 +843,8 @@ describe("Board grid", () => {
}),
).toBe(true);
await page.keyboard.press("Escape");
await expect(resizeOutline).toHaveCount(0);
await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting");
await page.mouse.up();
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
+4 -1
View File
@@ -36,7 +36,10 @@ describe("Custom JSX v2 workbench", () => {
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 15_000 });
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
waitUntil: "commit",
timeout: 60_000,
});
await page.locator("button[type='submit']").click();
await signedIn;
+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,
});