@@ -968,7 +936,6 @@ const DndGridEntryComponent = ({
sectionId={sectionId}
placement={placement}
label={label}
- disabled={placement.type === "section" && !isSectionChromeActive}
columnCount={columnCount}
maxRowCount={maxRowCount}
beginResize={beginResize}
@@ -987,7 +954,6 @@ interface GridResizeHandlesProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
- disabled: boolean;
columnCount: number;
maxRowCount: number | null;
beginResize: BoardGridEditorActionsContextValue["beginResize"];
@@ -1003,7 +969,6 @@ const GridResizeHandles = ({
sectionId,
placement,
label,
- disabled,
columnCount,
maxRowCount,
beginResize,
@@ -1083,15 +1048,7 @@ const GridResizeHandles = ({
const handlePointerDown = (event: ReactPointerEvent
, direction: GridResizeDirection) => {
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
- if (
- disabled ||
- !event.isPrimary ||
- event.button !== 0 ||
- pointerStartRef.current ||
- !visualScale ||
- !start(direction)
- )
- return;
+ if (!event.isPrimary || event.button !== 0 || pointerStartRef.current || !visualScale || !start(direction)) return;
event.preventDefault();
event.stopPropagation();
try {
@@ -1150,7 +1107,6 @@ const GridResizeHandles = ({
key={direction}
className="board-grid-resize-handle"
data-grid-resize-handle={direction}
- data-grid-resize-disabled={disabled ? "true" : undefined}
aria-hidden="true"
onPointerDown={(event) => handlePointerDown(event, direction)}
onPointerMove={handlePointerMove}
@@ -1281,40 +1237,17 @@ const getPreferredRegisteredGridTarget = (
return registeredGrids.find((grid) => grid.id === preferredId) ?? pointerTarget;
};
-const doesPreviewDisplaceNestedGridOwner = (
- snapshot: TransactionalGridState,
- preview: TransactionalGridState,
- targetGridId: string,
- activeId: string,
-) => {
- const ownerIds = new Set(
- snapshot.grids
- .filter((grid) => grid.parentGridId === targetGridId && grid.ownerPlacementId !== activeId)
- .flatMap((grid) => (grid.ownerPlacementId ? [grid.ownerPlacementId] : [])),
- );
- if (ownerIds.size === 0) return false;
-
- const original = snapshot.grids.find((grid) => grid.id === targetGridId);
- const candidate = preview.grids.find((grid) => grid.id === targetGridId);
- if (!original || !candidate) return false;
- const originalById = new Map(original.placements.map((placement) => [placement.id, placement]));
- return candidate.placements.some((placement) => {
- const previous = originalById.get(placement.id);
- return Boolean(previous && ownerIds.has(placement.id) && !areGridPlacementCoordinatesEqual(previous, placement));
- });
-};
-
-const areGridPlacementCoordinatesEqual = (first: GridPlacement, second: GridPlacement) =>
- first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
-
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
const target = event.target;
- if (!(target instanceof Element) || target === source.handle) return false;
+ const activationElement = source.handle ?? source.element;
+ if (!(target instanceof Element) || target === activationElement) return false;
+ const targetShell = target.closest(".board-grid-entry");
+ if (targetShell && targetShell !== activationElement) return true;
const interactive = target.closest(
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag],.board-grid-resize-handle',
);
- return Boolean(interactive && source.handle?.contains(interactive));
+ return Boolean(interactive && activationElement?.contains(interactive));
};
const getEntryVisualScale = (element: HTMLElement | null, placement: SectionGridPlacement) => {
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx
index f08afa600..e30af1daf 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx
@@ -12,7 +12,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { getRootSectionLane } from "@homarr/definitions";
import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types";
-import { getBoardLaneColumnCount } from "~/components/board/layout";
+import { getBoardLaneColumnCount, normalizeGridPlacement } from "~/components/board/layout";
import { SectionContentItem } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
@@ -23,6 +23,7 @@ interface GridPortalHostContextValue {
integrations: RouterOutputs["integration"]["all"] | undefined;
containers: ReadonlyMap;
acquireContainer: (id: string) => HTMLElement;
+ structureRevision: string;
}
const GridPortalHostContext = createContext(null);
@@ -50,6 +51,7 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
() => new Set([...board.items.map((item) => item.id), ...board.sections.map((section) => section.id)]),
[board.items, board.sections],
);
+ const structureRevision = useMemo(() => [...liveEntryIds].toSorted().join(":"), [liveEntryIds]);
const acquireContainer = useCallback((id: string) => {
const existing = containersRef.current.get(id);
@@ -81,8 +83,9 @@ export const BoardGridPortalHost = ({ children }: PropsWithChildren) => {
integrations,
containers,
acquireContainer,
+ structureRevision,
}),
- [acquireContainer, announce, containers, integrations],
+ [acquireContainer, announce, containers, integrations, structureRevision],
);
return (
@@ -143,26 +146,23 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const { items, innerSections } = useSectionItems(ownerSectionId);
- const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId);
- const entry = persistedEntry;
- const rawSection = board.sections.find((section) => section.id === ownerSectionId);
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
- const persistedSection = toGridSection(rawSection, currentLayoutId);
- const section = persistedSection;
+ const section = currentLayout ? getRenderedSection(board, currentLayout, ownerSectionId) : null;
- if (!entry || !section || !currentLayout) return null;
+ if (!section || !currentLayout) return null;
+
+ const columnCount = getSectionColumnCount(section, currentLayout);
+ if (columnCount < 1) return null;
+ const renderedItems = items.map((item) => normalizePortalEntry(item, columnCount));
+ const renderedInnerSections = innerSections.map((innerSection) => normalizePortalEntry(innerSection, columnCount));
+ const entry = [...renderedItems, ...renderedInnerSections].find((candidate) => candidate.id === itemId);
+ if (!entry) return null;
- const columnCount =
- section.kind === "container"
- ? section.width
- : section.kind === "empty"
- ? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
- : currentLayout.columnCount;
const configuredMaxRowCount = section.kind === "container" ? section.height : null;
const contentRowCount = Math.max(
1,
- ...items.map((item) => item.yOffset + item.height),
- ...innerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
+ ...renderedItems.map((item) => item.yOffset + item.height),
+ ...renderedInnerSections.map((innerSection) => innerSection.yOffset + innerSection.height),
);
const maxRowCount = configuredMaxRowCount === null ? null : Math.max(configuredMaxRowCount, contentRowCount);
@@ -170,8 +170,8 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G
,
+ currentLayout: ReturnType["layouts"][number],
+ sectionId: string,
+ visited = new Set(),
): Exclude | ContainerSectionItem | null => {
+ const section = board.sections.find((candidate) => candidate.id === sectionId);
if (!section) return null;
if (section.kind !== "container") return section;
+ if (visited.has(section.id)) return null;
- const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId);
+ const layout = section.layouts.find((candidate) => candidate.layoutId === currentLayout.id);
if (!layout) return null;
+ const parentSection = getRenderedSection(
+ board,
+ currentLayout,
+ layout.parentSectionId,
+ new Set(visited).add(section.id),
+ );
+ if (!parentSection) return null;
+ const parentColumnCount = getSectionColumnCount(parentSection, currentLayout);
+ if (parentColumnCount < 1) return null;
+ const normalized = normalizePortalEntry(layout, parentColumnCount);
return {
...section,
- ...layout,
+ ...normalized,
type: "section",
};
};
+const getSectionColumnCount = (
+ section: Exclude | ContainerSectionItem,
+ currentLayout: ReturnType["layouts"][number],
+) =>
+ section.kind === "container"
+ ? section.width
+ : section.kind === "empty"
+ ? getBoardLaneColumnCount(currentLayout, getRootSectionLane(section.xOffset))
+ : currentLayout.columnCount;
+
+const normalizePortalEntry = <
+ TEntry extends { id?: string; xOffset: number; yOffset: number; width: number; height: number },
+>(
+ entry: TEntry,
+ columnCount: number,
+) => {
+ const placement = normalizeGridPlacement(
+ { id: entry.id ?? "portal-entry", x: entry.xOffset, y: entry.yOffset, w: entry.width, h: entry.height },
+ columnCount,
+ );
+ return {
+ ...entry,
+ xOffset: placement.x,
+ yOffset: placement.y,
+ width: placement.w,
+ height: placement.h,
+ };
+};
+
const getOwnerSectionId = (board: ReturnType, layoutId: string, entryId: string) => {
const item = board.items.find((candidate) => candidate.id === entryId);
if (item) {
diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
index 664ea2d15..e2c990f5a 100644
--- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
@@ -172,8 +172,10 @@ export const SectionGrid = ({
{
}
}, 120_000);
- test("Credentials onboarding should be successful", async () => {
- // Arrange
- const sqlite = await createSqliteDbFileAsync();
- const { db, localMountPath } = sqlite;
- const homarrContainer = await createHomarrContainer({
- mounts: {
- "/appdata": localMountPath,
- },
- }).start();
-
- const browser = await chromium.launch();
- const context = await browser.newContext();
- const page = await context.newPage();
- const actions = new OnboardingActions(page, db);
- const assertions = new OnboardingAssertions(page, db);
-
- try {
- // Act
- await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
- await actions.startOnboardingAsync();
-
- const competingContext = await browser.newContext();
- const claimResponse = await competingContext.request.post(
- `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/api/onboarding/claim`,
- );
- expect(claimResponse.status()).toBe(423);
- await competingContext.close();
-
- await actions.processUserStepAsync({
- username: "admin",
- password: "Comp(exP4sswOrd",
- confirmPassword: "Comp(exP4sswOrd",
- });
- await actions.processSettingsStepAsync();
- await actions.processIntegrationsStepAsync();
-
- // Assert
- await assertions.assertFinishStepVisibleAsync();
- await assertions.assertUserAndAdminGroupInsertedAsync("admin");
- await assertions.assertDbOnboardingStepAsync("finish");
- } finally {
- try {
- await Promise.all([browser.close(), homarrContainer.stop()]);
- } finally {
- await cleanupSqliteDbAsync(sqlite);
- }
- }
- }, 60_000);
-
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
const sqlite = await createSqliteDbFileAsync();
const { db, localMountPath } = sqlite;
diff --git a/packages/spotlight/src/modes/home/context.tsx b/packages/spotlight/src/modes/home/context.tsx
index 3aec94589..697a8c220 100644
--- a/packages/spotlight/src/modes/home/context.tsx
+++ b/packages/spotlight/src/modes/home/context.tsx
@@ -19,13 +19,18 @@ export type ContextSpecificItem = {
interface SpotlightContextProps {
items: ContextSpecificItem[];
+}
+
+interface SpotlightRegistrationContextProps {
registerItems: (key: string, results: ContextSpecificItem[]) => void;
unregisterItems: (key: string) => void;
}
const createSpotlightContext = (displayName: string) => {
const SpotlightContext = createContext(null);
+ const SpotlightRegistrationContext = createContext(null);
SpotlightContext.displayName = displayName;
+ SpotlightRegistrationContext.displayName = `${displayName}Registration`;
const Provider = ({ children }: PropsWithChildren) => {
const [itemsMap, setItemsMap] = useState>(new Map());
@@ -51,16 +56,18 @@ const createSpotlightContext = (displayName: string) => {
const newItemsMap = new Map(prevItems);
newItemsMap.set(key, { items: newItemsMap.get(key)?.items ?? [], count: registrationCount - 1 });
- return prevItems;
+ return newItemsMap;
});
}, []);
const items = useMemo(() => Array.from(itemsMap.values()).flatMap(({ items }) => items), [itemsMap]);
+ const itemsContext = useMemo(() => ({ items }), [items]);
+ const registration = useMemo(() => ({ registerItems, unregisterItems }), [registerItems, unregisterItems]);
return (
-
- {children}
-
+
+ {children}
+
);
};
@@ -79,7 +86,7 @@ const createSpotlightContext = (displayName: string) => {
items: ContextSpecificItem[],
dependencyArray: DependencyList,
) => {
- const context = useContext(SpotlightContext);
+ const context = useContext(SpotlightRegistrationContext);
if (!context) {
throw new Error(
@@ -97,7 +104,7 @@ const createSpotlightContext = (displayName: string) => {
context.unregisterItems(key);
};
// We ignore the results
- }, [...dependencyArray, key]);
+ }, [...dependencyArray, context.registerItems, context.unregisterItems, key]);
};
return [SpotlightContext, Provider, useSpotlightContextItems, useRegisterSpotlightContextItems] as const;
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 99890495a..6943988f1 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -2710,7 +2710,7 @@
},
"remove": {
"title": "Remove item",
- "message": "Are you sure you want to remove this item?"
+ "message": "Are you sure?"
},
"advancedFocus": {
"open": "Open advanced view",
@@ -2807,9 +2807,16 @@
"withBorder": {
"label": "Show border"
},
+ "customUrls": {
+ "label": "Custom links",
+ "description": "Add URLs directly without creating an App first"
+ },
"items": {
"label": "Bookmarks",
- "add": "Add bookmark"
+ "apps": {
+ "label": "Apps",
+ "placeholder": "Select one or more apps"
+ }
}
}
},
diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx
index abeb7e350..ca10d8d5b 100644
--- a/packages/widgets/src/_inputs/common.tsx
+++ b/packages/widgets/src/_inputs/common.tsx
@@ -6,7 +6,7 @@ import type { WidgetOptionOfType, WidgetOptionType } from "../options";
export interface CommonWidgetInputProps {
kind: WidgetKind;
property: string;
- options: Omit, "defaultValue" | "type">;
+ options: Omit, "type">;
initialOptions: Record;
itemId?: string;
boardId?: string;
diff --git a/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx b/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx
index 95103f25f..262b60671 100644
--- a/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx
+++ b/packages/widgets/src/_inputs/widget-custom-widget-select-input.spec.tsx
@@ -114,7 +114,7 @@ async function renderInput() {
,
);
diff --git a/packages/widgets/src/_inputs/widget-location-input.spec.tsx b/packages/widgets/src/_inputs/widget-location-input.spec.tsx
new file mode 100644
index 000000000..ec5d0ccb6
--- /dev/null
+++ b/packages/widgets/src/_inputs/widget-location-input.spec.tsx
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import type { ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import type { Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { WidgetLocationInput } from "./widget-location-input";
+
+const defaultLocation = { name: "Paris", latitude: 48.8566, longitude: 2.3522 };
+
+const mocks = vi.hoisted(() => ({
+ values: {} as Record,
+ setFieldValue: vi.fn(),
+}));
+
+vi.mock("@mantine/core", () => {
+ const Container = ({ children }: { children?: ReactNode }) => {children}
;
+ const Input = ({ label, value }: { label?: ReactNode; value?: string | number }) => (
+
+ {label}
+
+
+ );
+
+ return {
+ Button: Container,
+ Fieldset: Container,
+ Group: Container,
+ NumberInput: Input,
+ Stack: Container,
+ TextInput: Input,
+ Tooltip: Container,
+ };
+});
+
+vi.mock("@homarr/modals", () => ({
+ createModal: () => ({ withOptions: () => null }),
+ useModalAction: () => ({ openModal: vi.fn() }),
+}));
+
+vi.mock("@homarr/translation/client", () => ({
+ useCurrentIntlLocale: () => "en",
+ useScopedI18n: () => (key: string) => key,
+}));
+
+vi.mock("./common", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useWidgetInputTranslation: () => (key: string) => key,
+}));
+
+vi.mock("./form", () => ({
+ useFormContext: () => ({
+ getInputProps: (path: string) => ({ value: mocks.values[path], onChange: vi.fn() }),
+ setFieldValue: mocks.setFieldValue,
+ clearFieldError: vi.fn(),
+ watch: vi.fn(),
+ }),
+}));
+
+let root: Root;
+let host: HTMLDivElement;
+
+beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ mocks.values = {};
+ mocks.setFieldValue.mockReset();
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
+
+afterEach(async () => {
+ await act(() => root.unmount());
+ host.remove();
+});
+
+const renderInput = async () => {
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+};
+
+describe("location input initialization", () => {
+ it("hydrates a missing location with the default value", async () => {
+ await renderInput();
+
+ expect(mocks.setFieldValue).toHaveBeenCalledOnce();
+ expect(mocks.setFieldValue).toHaveBeenCalledWith("options.location", defaultLocation);
+ });
+
+ it("does not reset a transient empty coordinate after initialization", async () => {
+ mocks.values = {
+ "options.location": defaultLocation,
+ "options.location.name": defaultLocation.name,
+ "options.location.latitude": defaultLocation.latitude,
+ "options.location.longitude": defaultLocation.longitude,
+ };
+ await renderInput();
+
+ mocks.values = {
+ ...mocks.values,
+ "options.location": { ...defaultLocation, latitude: "" },
+ "options.location.latitude": "",
+ };
+ await renderInput();
+
+ expect(mocks.setFieldValue).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx
index 91501aa2c..91c7d0dd1 100644
--- a/packages/widgets/src/_inputs/widget-location-input.tsx
+++ b/packages/widgets/src/_inputs/widget-location-input.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback } from "react";
+import { useCallback, useEffect, useRef } from "react";
import {
ActionIcon,
Alert,
@@ -29,14 +29,26 @@ import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
-export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"location">) => {
+export const WidgetLocationInput = ({ property, kind, options }: CommonWidgetInputProps<"location">) => {
const t = useWidgetInputTranslation(kind, property);
const tLocation = useScopedI18n("widget.common.location");
const form = useFormContext();
const { openModal } = useModalAction(LocationSearchModal);
- const inputProps = form.getInputProps(`options.${property}`);
- const value = inputProps.value as OptionLocation;
+ const fieldPath = `options.${property}`;
+ const inputProps = form.getInputProps(fieldPath);
+ const value = isOptionLocation(inputProps.value) ? inputProps.value : options.defaultValue;
+ const nameInputProps = form.getInputProps(`options.${property}.name`);
+ const latitudeInputProps = form.getInputProps(`options.${property}.latitude`);
+ const longitudeInputProps = form.getInputProps(`options.${property}.longitude`);
const selectionEnabled = value.name.length > 1;
+ const initializedFieldPathRef = useRef(null);
+
+ useEffect(() => {
+ if (initializedFieldPathRef.current === fieldPath) return;
+ initializedFieldPathRef.current = fieldPath;
+ if (isOptionLocation(inputProps.value)) return;
+ form.setFieldValue(fieldPath, options.defaultValue);
+ }, [fieldPath, form, inputProps.value, options.defaultValue]);
const handleChange = inputProps.onChange as LocationOnChange;
const unknownLocation = tLocation("unknownLocation");
@@ -73,7 +85,12 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
-
+
@@ -107,6 +126,16 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
);
};
+const isOptionLocation = (value: unknown): value is OptionLocation =>
+ typeof value === "object" &&
+ value !== null &&
+ "name" in value &&
+ typeof value.name === "string" &&
+ "latitude" in value &&
+ typeof value.latitude === "number" &&
+ "longitude" in value &&
+ typeof value.longitude === "number";
+
type LocationOnChange = (
location: Pick
& {
latitude: OptionLocation["latitude"] | "";
diff --git a/packages/widgets/src/_inputs/widget-multi-text-input.tsx b/packages/widgets/src/_inputs/widget-multi-text-input.tsx
index 864f200b5..aefc897aa 100644
--- a/packages/widgets/src/_inputs/widget-multi-text-input.tsx
+++ b/packages/widgets/src/_inputs/widget-multi-text-input.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import { Combobox, Pill, PillsInput, useCombobox } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
@@ -18,10 +18,16 @@ export const WidgetMultiTextInput = ({ property, kind, options }: CommonWidgetIn
const [search, setSearch] = useState("");
const form = useFormContext();
- const inputProps = form.getInputProps(`options.${property}`);
- const values = inputProps.value as string[];
+ const fieldPath = `options.${property}`;
+ const inputProps = form.getInputProps(fieldPath);
+ const values = Array.isArray(inputProps.value) ? (inputProps.value as string[]) : options.defaultValue;
const onChange = inputProps.onChange as (values: string[]) => void;
+ useEffect(() => {
+ if (Array.isArray(inputProps.value)) return;
+ form.setFieldValue(fieldPath, options.defaultValue);
+ }, [fieldPath, form, inputProps.value, options.defaultValue]);
+
const handleRemove = (optionIndex: number) => {
onChange(values.filter((_, index) => index !== optionIndex));
};
diff --git a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx
index 6a8c66eaf..7789a5fb8 100644
--- a/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx
+++ b/packages/widgets/src/_inputs/widget-sortable-item-list-input.tsx
@@ -31,8 +31,14 @@ export const WidgetSortedItemListInput = ) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
- const initialValues = useMemo(() => initialOptions[property] as TOptionValue[], [initialOptions, property]);
- const values = form.values.options[property] as TOptionValue[];
+ const fieldPath = `options.${property}`;
+ const initialValues = useMemo(
+ () =>
+ Array.isArray(initialOptions[property]) ? (initialOptions[property] as TOptionValue[]) : options.defaultValue,
+ [initialOptions, options.defaultValue, property],
+ );
+ const currentValue = form.values.options[property];
+ const values = Array.isArray(currentValue) ? (currentValue as TOptionValue[]) : options.defaultValue;
const { data, isLoading, error } = options.useData(initialValues);
const dataMap = useMemo(
() => new Map(data?.map((item) => [options.uniqueIdentifier(item), item as TItem])),
@@ -52,6 +58,11 @@ export const WidgetSortedItemListInput = values.indexOf(id);
const activeIndex = activeId ? getIndex(activeId) : -1;
+ useEffect(() => {
+ if (Array.isArray(currentValue)) return;
+ form.setFieldValue(fieldPath, options.defaultValue);
+ }, [currentValue, fieldPath, form, options.defaultValue]);
+
useEffect(() => {
if (!activeId) {
isFirstAnnouncement.current = true;
@@ -69,22 +80,39 @@ export const WidgetSortedItemListInput = TOptionValue[]) => {
- form.setFieldValue(`options.${property}`, callback);
- };
+ const updateItems = useCallback(
+ (callback: (prev: TOptionValue[]) => TOptionValue[]) => {
+ form.setFieldValue(fieldPath, callback);
+ },
+ [fieldPath, form],
+ );
const addItem = (item: TItem) => {
setTempMap((prev) => {
- prev.set(options.uniqueIdentifier(item) as TOptionValue, item);
- return prev;
+ const next = new Map(prev);
+ next.set(options.uniqueIdentifier(item) as TOptionValue, item);
+ return next;
});
- updateItems((values) => [...values, options.uniqueIdentifier(item) as TOptionValue]);
+ updateItems((currentValues) => [...currentValues, options.uniqueIdentifier(item) as TOptionValue]);
};
+ const removeItem = useCallback(
+ (value: TOptionValue) => {
+ updateItems((currentValues) => currentValues.filter((candidate) => candidate !== value));
+ setTempMap((previous) => {
+ if (!previous.has(value)) return previous;
+ const next = new Map(previous);
+ next.delete(value);
+ return next;
+ });
+ },
+ [updateItems],
+ );
+
return (
-
+
{values.map((value, index) => {
const item = getItem(value);
- const removeItem = () => {
- form.setValues((previous) => {
- const previousValues = previous.options?.[property] as TOptionValue[];
- return {
- ...previous,
- options: {
- ...previous.options,
- [property]: previousValues.filter((id) => id !== value),
- },
- };
- });
- };
-
if (!item) {
return null;
}
@@ -137,7 +152,7 @@ export const WidgetSortedItemListInput = removeItem(value)}
options={options}
/>
);
diff --git a/packages/widgets/src/beszel-system-grid/component.module.css b/packages/widgets/src/beszel-system-grid/component.module.css
index 94c951dca..41bc5efed 100644
--- a/packages/widgets/src/beszel-system-grid/component.module.css
+++ b/packages/widgets/src/beszel-system-grid/component.module.css
@@ -1,18 +1,26 @@
.clickableCard {
+ position: relative;
transition: background-color 150ms;
cursor: pointer;
}
.clickableCard:hover,
-.clickableCard:focus-visible {
+.clickableCard:focus-within {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
-.clickableCard:focus-visible {
+.clickableCard:focus-within {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
+.cardAction {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ border-radius: inherit;
+}
+
@media (prefers-reduced-motion: reduce) {
.clickableCard {
transition: none;
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx
index 84f796fe2..c3d56cce7 100644
--- a/packages/widgets/src/beszel-system-grid/component.tsx
+++ b/packages/widgets/src/beszel-system-grid/component.tsx
@@ -1,7 +1,7 @@
"use client";
import type { MantineSize } from "@mantine/core";
-import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text } from "@mantine/core";
+import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text, UnstyledButton } from "@mantine/core";
import {
Activity,
Battery,
@@ -365,14 +365,10 @@ const SystemCard = ({
return (
+ {onClick && (
+
+ )}
["AddButton"] = ({ addItem, values }) => {
- const { openModal } = useModalAction(AppSelectModal);
- const t = useI18n();
+type SelectableApp = RouterOutputs["app"]["selectable"][number];
+
+export const BookmarkAddButton: SortableItemListInput["AddButton"] = ({
+ addItem,
+ removeItem,
+ values,
+}) => {
+ const t = useScopedI18n("widget.bookmarks.option.items.apps");
+ const tCommon = useScopedI18n("common");
+ const tSelect = useScopedI18n("app.action.select");
+ const { data: apps = [], isPending, error } = clientApi.app.selectable.useQuery();
+ const appsById = new Map(apps.map((app) => [app.id, app]));
+
+ const handleChange = (nextValues: string[]) => {
+ const nextIds = new Set(nextValues);
+ for (const value of values) {
+ if (!nextIds.has(value)) removeItem(value);
+ }
+
+ const currentIds = new Set(values);
+ for (const value of nextValues) {
+ if (currentIds.has(value)) continue;
+ const app = appsById.get(value);
+ if (app) addItem(app);
+ }
+ };
return (
- openModal({ onSelect: addItem, presentAppIds: values })}>
- {t("widget.bookmarks.option.items.add")}
-
+ : undefined}
+ nothingFoundMessage={tSelect("notFound")}
+ renderOption={renderAppOption}
+ data={apps.map((app) => ({
+ value: app.id,
+ label: app.name,
+ iconUrl: app.iconUrl,
+ }))}
+ />
);
};
+
+const iconProps = {
+ stroke: 1.5,
+ color: "currentColor",
+ opacity: 0.6,
+ size: 18,
+};
+
+const renderAppOption: MultiSelectProps["renderOption"] = ({ option, checked }) => (
+
+ {"iconUrl" in option && typeof option.iconUrl === "string" ? (
+
+ ) : null}
+ {option.label}
+ {checked && }
+
+);
diff --git a/packages/widgets/src/bookmarks/app-select-modal.tsx b/packages/widgets/src/bookmarks/app-select-modal.tsx
deleted file mode 100644
index 51b26aa8b..000000000
--- a/packages/widgets/src/bookmarks/app-select-modal.tsx
+++ /dev/null
@@ -1,109 +0,0 @@
-"use client";
-
-import { memo, useState } from "react";
-import type { SelectProps } from "@mantine/core";
-import { Group, Loader, Select, Stack } from "@mantine/core";
-import { IconCheck } from "@tabler/icons-react";
-
-import type { RouterOutputs } from "@homarr/api";
-import { clientApi } from "@homarr/api/client";
-import { useForm } from "@homarr/form";
-import { createModal, ModalFormFooter, modalSizeForm } from "@homarr/modals";
-import { useI18n } from "@homarr/translation/client";
-
-interface InnerProps {
- presentAppIds: string[];
- onSelect: (props: RouterOutputs["app"]["selectable"][number]) => void | Promise;
- confirmLabel?: string;
-}
-
-interface AppSelectFormType {
- id: string;
-}
-
-export const AppSelectModal = createModal(({ actions, innerProps }) => {
- const t = useI18n();
- const { data: apps, isPending } = clientApi.app.selectable.useQuery();
- const [loading, setLoading] = useState(false);
- const form = useForm();
- const handleSubmitAsync = async (values: AppSelectFormType) => {
- const currentApp = apps?.find((app) => app.id === values.id);
- if (!currentApp) return;
- setLoading(true);
- await innerProps.onSelect(currentApp);
-
- setLoading(false);
- actions.closeModal();
- };
-
- const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
- const currentApp = apps?.find((app) => app.id === form.values.id);
-
- return (
-
- );
-}).withOptions({
- defaultTitle: (t) => t("app.action.select.label"),
- size: modalSizeForm,
-});
-
-const iconProps = {
- stroke: 1.5,
- color: "currentColor",
- opacity: 0.6,
- size: 18,
-};
-
-const renderSelectOption: SelectProps["renderOption"] = ({ option, checked }) => (
-
- {"iconUrl" in option && typeof option.iconUrl === "string" ? (
-
- ) : null}
- {option.label}
- {checked && }
-
-);
-
-interface LeftSectionProps {
- isPending: boolean;
- currentApp: RouterOutputs["app"]["selectable"][number] | undefined;
-}
-
-const size = 20;
-const LeftSection = ({ isPending, currentApp }: LeftSectionProps) => {
- if (isPending) {
- return ;
- }
-
- if (currentApp) {
- return ;
- }
-
- return null;
-};
-
-const MemoizedLeftSection = memo(LeftSection);
diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx
index d16e08ee4..de8cd67ee 100644
--- a/packages/widgets/src/bookmarks/component.tsx
+++ b/packages/widgets/src/bookmarks/component.tsx
@@ -1,5 +1,6 @@
"use client";
+import type { CSSProperties } from "react";
import { useMemo } from "react";
import {
Anchor,
@@ -14,9 +15,9 @@ import {
Title,
UnstyledButton,
} from "@mantine/core";
+import { IconLink } from "@tabler/icons-react";
import combineClasses from "clsx";
-import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
@@ -29,6 +30,14 @@ import classes from "./bookmark.module.css";
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
+export interface BookmarkDisplayItem {
+ id: string;
+ name: string;
+ description: string | null;
+ iconUrl?: string;
+ href: string | null;
+}
+
export interface CompactBookmarkLayout {
columns: number;
hideHostname: boolean;
@@ -67,14 +76,19 @@ export default function BookmarksWidget({
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
- const data =
+ const apps =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, {
- select(apps) {
- return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
+ select(selectedApps) {
+ return selectedApps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
}),
) ?? [];
+ const customLinks = useMemo(
+ () => options.customUrls.flatMap((url) => createCustomBookmark(url) ?? []),
+ [options.customUrls],
+ );
+ const data: BookmarkDisplayItem[] = [...apps, ...customLinks];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
@@ -85,7 +99,7 @@ export default function BookmarksWidget({
{
id: app.id,
name: app.name,
- icon: app.iconUrl,
+ icon: app.iconUrl ?? IconLink,
interaction() {
return {
type: "link",
@@ -161,7 +175,7 @@ export default function BookmarksWidget({
}
interface FlexLayoutProps {
- data: RouterOutputs["app"]["byIds"];
+ data: BookmarkDisplayItem[];
direction: "row" | "column";
hideTitle: boolean;
hideIcon: boolean;
@@ -235,7 +249,7 @@ const FlexLayout = ({
};
interface AdvancedBookmarksLayoutProps {
- data: RouterOutputs["app"]["byIds"];
+ data: BookmarkDisplayItem[];
width: number;
openNewTab: boolean;
withBorder: boolean;
@@ -250,6 +264,19 @@ export const getBookmarkHostname = (href: string | null): string | undefined =>
return safeHref ? new URL(safeHref).hostname : undefined;
};
+export const createCustomBookmark = (url: string): BookmarkDisplayItem | null => {
+ const normalizedUrl = url.trim();
+ const href = getSafeAppHref(normalizedUrl);
+ if (!href) return null;
+
+ return {
+ id: `custom-link:${normalizedUrl}`,
+ name: getBookmarkHostname(href) ?? normalizedUrl,
+ description: null,
+ href,
+ };
+};
+
const AdvancedBookmarksLayout = ({
data,
width,
@@ -275,10 +302,9 @@ const AdvancedBookmarksLayout = ({
>
-
@@ -306,7 +332,7 @@ const AdvancedBookmarksLayout = ({
};
interface GridLayoutProps {
- data: RouterOutputs["app"]["byIds"];
+ data: BookmarkDisplayItem[];
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
@@ -392,7 +418,7 @@ const VerticalItem = ({
hideHostname,
hasIconColor,
}: {
- app: RouterOutputs["app"]["byIds"][number];
+ app: BookmarkDisplayItem;
hideTitle: boolean;
hideIcon: boolean;
hideHostname: boolean;
@@ -406,10 +432,9 @@ const VerticalItem = ({
)}
{!hideIcon && (
-
{!hideIcon && (
-
);
};
+
+interface BookmarkIconProps {
+ app: BookmarkDisplayItem;
+ hasColor: boolean;
+ className?: string;
+ style?: CSSProperties;
+}
+
+const BookmarkIcon = ({ app, hasColor, className, style }: BookmarkIconProps) =>
+ app.iconUrl ? (
+
+ ) : (
+
+ );
diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx
index 15a208ce4..9f44a2617 100644
--- a/packages/widgets/src/bookmarks/index.tsx
+++ b/packages/widgets/src/bookmarks/index.tsx
@@ -1,9 +1,11 @@
import { ActionIcon, Avatar, Group, Stack, Text } from "@mantine/core";
import { IconBookmark, IconX } from "@tabler/icons-react";
+import { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
+import { getSafeAppHref } from "../common/application-url";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
import { BookmarkAddButton } from "./add-button";
@@ -27,7 +29,11 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
hideHostname: factory.switch({ defaultValue: false }),
openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
- items: factory.sortableItemList({
+ customUrls: factory.multiText({
+ validate: z.string().refine((value) => getSafeAppHref(value) !== undefined),
+ withDescription: true,
+ }),
+ items: factory.sortableItemList({
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
return (
diff --git a/packages/widgets/src/bookmarks/layout.spec.ts b/packages/widgets/src/bookmarks/layout.spec.ts
index 5ff6eeb33..1b13809db 100644
--- a/packages/widgets/src/bookmarks/layout.spec.ts
+++ b/packages/widgets/src/bookmarks/layout.spec.ts
@@ -1,6 +1,11 @@
import { describe, expect, test } from "vitest";
-import { getAdvancedBookmarkColumns, getBookmarkHostname, getCompactBookmarkLayout } from "./component";
+import {
+ createCustomBookmark,
+ getAdvancedBookmarkColumns,
+ getBookmarkHostname,
+ getCompactBookmarkLayout,
+} from "./component";
describe("bookmark display helpers", () => {
test("uses bounded advanced columns", () => {
@@ -30,4 +35,20 @@ describe("bookmark display helpers", () => {
expect(getBookmarkHostname("not a URL")).toBeUndefined();
expect(getBookmarkHostname(null)).toBeUndefined();
});
+
+ test("creates standalone bookmarks without app records", () => {
+ expect(createCustomBookmark("https://homarr.dev/docs")).toEqual({
+ id: "custom-link:https://homarr.dev/docs",
+ name: "homarr.dev",
+ description: null,
+ href: "https://homarr.dev/docs",
+ });
+ expect(createCustomBookmark(" https://homarr.dev/docs ")).toEqual({
+ id: "custom-link:https://homarr.dev/docs",
+ name: "homarr.dev",
+ description: null,
+ href: "https://homarr.dev/docs",
+ });
+ expect(createCustomBookmark("javascript:alert(1)")).toBeNull();
+ });
});
diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx
index aa3b1e765..04f982681 100644
--- a/packages/widgets/src/downloads/component.tsx
+++ b/packages/widgets/src/downloads/component.tsx
@@ -755,7 +755,7 @@ export default function DownloadClientsWidget({
{statsDisplay.visible && (
= 96) && (
,
"withDescription"
> {
- AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
+ AddButton: (props: {
+ addItem: (item: TItem) => void;
+ removeItem: (value: TOptionValue) => void;
+ values: TOptionValue[];
+ }) => React.ReactNode;
ItemComponent: (props: {
item: TItem;
removeItem: () => void;