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:
1 parent
b491d45fdc
commit
4505ce96c9
37 files changed
+824
-503
No files matched your search
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user