fix: refine dashboard interactions and widget options (#6597)

* fix: refine dashboard item interactions

* fix board interactions and widget options

* feat: improve bookmark selection

* fix: address dashboard review feedback
This commit is contained in:
Thomas authored and GitHub committed 2026-08-15 10:44:37 +02:00
1 parent b491d45fdc
commit 4505ce96c9
37 files changed
+824 -503

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@ import type { WidgetOptionOfType, WidgetOptionType } from "../options";
export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
kind: WidgetKind;
property: string;
options: Omit<WidgetOptionOfType<TKey>, "defaultValue" | "type">;
options: Omit<WidgetOptionOfType<TKey>, "type">;
initialOptions: Record<string, unknown>;
itemId?: string;
boardId?: string;
@@ -114,7 +114,7 @@ async function renderInput() {
<WidgetCustomWidgetSelectInput
kind="customApi"
property="definitionId"
options={{ withDescription: false }}
options={{ defaultValue: "", withDescription: false }}
initialOptions={{}}
/>,
);
@@ -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<string, unknown>,
setFieldValue: vi.fn(),
}));
vi.mock("@mantine/core", () => {
const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>;
const Input = ({ label, value }: { label?: ReactNode; value?: string | number }) => (
<label>
{label}
<input value={value ?? ""} readOnly />
</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(
<WidgetLocationInput
kind="weather"
property="location"
options={{ defaultValue: defaultLocation }}
initialOptions={{}}
/>,
);
});
};
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();
});
});
@@ -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<string | null>(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<"
<Fieldset legend={t("label")}>
<Stack gap="xs">
<Group wrap="nowrap" align="end">
<TextInput w="100%" label={tLocation("query")} {...form.getInputProps(`options.${property}.name`)} />
<TextInput
w="100%"
label={tLocation("query")}
{...nameInputProps}
value={nameInputProps.value ?? value.name}
/>
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
<div>
<Button
@@ -93,13 +110,15 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
decimalScale={5}
label={tLocation("latitude")}
hideControls
{...form.getInputProps(`options.${property}.latitude`)}
{...latitudeInputProps}
value={latitudeInputProps.value ?? value.latitude}
/>
<NumberInput
decimalScale={5}
label={tLocation("longitude")}
hideControls
{...form.getInputProps(`options.${property}.longitude`)}
{...longitudeInputProps}
value={longitudeInputProps.value ?? value.longitude}
/>
</Group>
</Stack>
@@ -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<OptionLocation, "name"> & {
latitude: OptionLocation["latitude"] | "";
@@ -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));
};
@@ -31,8 +31,14 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
}: CommonWidgetInputProps<"sortableItemList">) => {
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 = <TItem, TOptionValue extends UniqueIden
const getIndex = (id: TOptionValue) => 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 = <TItem, TOptionValue extends UniqueIden
[tempMap, dataMap],
);
const updateItems = (callback: (prev: TOptionValue[]) => 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 (
<Fieldset legend={t("label")}>
<Stack>
<options.addButton addItem={addItem} values={values} />
<options.addButton addItem={addItem} removeItem={removeItem} values={values} />
<DndContext
sensors={sensors}
@@ -114,19 +142,6 @@ export const WidgetSortedItemListInput = <TItem, TOptionValue extends UniqueIden
<React.Fragment>
{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 = <TItem, TOptionValue extends UniqueIden
id={value}
index={index}
item={item}
removeItem={removeItem}
removeItem={() => removeItem(value)}
options={options}
/>
);