fix: address widget review feedback

This commit is contained in:
Thomas Camlong committed 2026-08-01 23:44:17 +02:00
1 parent cce3e455fa
commit c754946089
94 files changed
+2332 -322

No files matched your search

+3 -3
View File
@@ -36,13 +36,13 @@ Underscores and hyphen-minus are allowed.
### Column count
Homarr has a complex drag and drop system that can be used from the browser.
Homarr has a complex drag-and-drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
### Public
This will enable public access for everyone - regardless of whether they are logged in or not.
This can be really useful if you want to have public boards in your company or for family and fiends.
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
@@ -106,7 +106,7 @@ Amount of columns for this layout. Defines how many items can be placed in a row
#### Breakpoint
Minimum width of the screen where the layout will be used.
Once the minmum of the next layout is reached the layout will change.
Once the minimum width of the next layout is reached, the layout will change.
#### Preview
+2 -2
View File
@@ -21,12 +21,12 @@ You can add links to your favorite applications, websites, or any other resource
import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from "./img/vertical-list-all.png";
import vericalListSimple from "./img/vertical-list-simple.png";
import verticalListSimple from "./img/vertical-list-simple.png";
<div className='flex gap-1'>
<div className='flex flex-col gap-1'>
<img src={gridIconsOnly} alt="bookmarks grid icons-only" width={128} height={128} />
<img src={vericalListSimple} alt="bookmarks vertical-list no-domain" width={128} height={128} />
<img src={verticalListSimple} alt="bookmarks vertical-list no-domain" width={128} height={128} />
</div>
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
@@ -1,6 +1,6 @@
---
title: "DNS Hole Controls"
description: "Control PiHole or AdGuard from your dashboard."
description: "Control Pi-hole, AdGuard Home, or Technitium DNS from your dashboard."
hide_title: true
---
+6 -7
View File
@@ -39,13 +39,14 @@ import youtubeStream from "./img/nasa-iss-stream.png";
Although you can integrate many websites, some may not work.
This is because the administrators of those sites disallow iframes
for security reasons, as users could easily be tricked
by embeding the original site on a malicious page.
by embedding the original site on a malicious page.
Some proxies disallow iframes by default for this reason.
You can use https://iframetest.com to check, if the site is allowing iframes.
You can use https://iframetest.com to check whether the site allows iframes.
If you manage all of your own applications, you can usually change that by configuring
the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes.
the `Content-Security-Policy` header and its `frame-ancestors` directive. The obsolete
`X-Frame-Options: ALLOW-FROM` directive is not supported by modern browsers.
For example, to allow your site to be embedded in iframes from specific domains:
@@ -53,14 +54,12 @@ For example, to allow your site to be embedded in iframes from specific domains:
```apache
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
```
**Nginx:**
```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com";
```
### Example URLs
@@ -86,11 +85,11 @@ To create a transparent iframe like below you can use the following html code an
![transparent iframe](./img/transparent-iframe.png)
### Third party content
### Third-party content
:::warning
The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository.
The content below is an overview of third-party projects. All support requests/problems must be redirected to the appropriate repository.
We cannot guarantee the functionality nor security of these tools.
:::
@@ -1,6 +1,6 @@
---
title: "Media releases"
description: "Display newly added medias or upcoming releases from different integrations"
description: "Display newly added media or upcoming releases from different integrations"
hide_title: true
---
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections.
Provide an overview of your media requests, including total requests, open requests, approvals, and rejections.
### Supported Integrations
@@ -21,8 +21,8 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget enables you to watch and view current active sessions over all your media servers.
It also displays useful information, that you can use when providing support to users or debugging a problem.
The media server widget shows active sessions across your media servers.
It also displays useful information for user support and troubleshooting.
### Screenshots
@@ -26,7 +26,7 @@ import bedrockItem from "./img/bedrock.png";
<div className="flex gap-1">
<img src={javaItem} alt="minecraft-server-status java-edition" width={128 * 1.5} height={128 * 1.5} />
<img src={bedrockItem} alt="minecraft-server-status bedrock-editon" width={128 * 1.5} height={128 * 1.5} />
<img src={bedrockItem} alt="minecraft-server-status bedrock-edition" width={128 * 1.5} height={128 * 1.5} />
</div>
<WidgetInteractionGuide />
@@ -42,4 +42,4 @@ import bedrockItem from "./img/bedrock.png";
### Data source
We use the Minecraft Server Status API to fetch the server status.
If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us`
If you need to whitelist the API, use the following URL: `https://api.mcsrvstat.us`.
@@ -34,7 +34,7 @@ import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
import type { WidgetDefinition } from "@homarr/widgets";
import { widgetImports } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -96,8 +96,11 @@ export function TRPCReactProvider(props: PropsWithChildren) {
const def = definition as WidgetDefinition & { kind: string };
if (def.refetchInterval === undefined) continue;
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
const primaryQueryKey = def.queryKey ?? [["widget", def.kind]];
client.setQueryDefaults(primaryQueryKey, { refetchInterval: interval });
for (const queryKey of getWidgetQueryKeys(def)) {
const path = queryKey[0];
if (!Array.isArray(path) || path[0] !== "widget") continue;
client.setQueryDefaults(queryKey, { refetchInterval: interval });
}
}
return client;
});
@@ -135,7 +135,7 @@
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
animation-duration: 1ms;
animation: none;
}
}
@@ -14,7 +14,7 @@ import {
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
@@ -28,8 +28,13 @@ import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { getWidgetQueryKeys, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets";
import {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
reduceWidgetOptionsWithDefaultValues,
widgetImports,
} from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
@@ -38,6 +43,7 @@ import { useBoardPermissions } from "../permissions/client";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
@@ -70,13 +76,45 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: t("item.menu.notification.saveError.title"),
message: t("item.menu.notification.saveError.message"),
});
},
});
},
[t, updateAndPersistBoard],
);
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
const widgetQueryKeys = useMemo(
() => getWidgetQueryKeys(currentDefinition as Parameters<typeof getWidgetQueryKeys>[0]),
[currentDefinition],
);
const widgetQueryMatcher = (currentDefinition as WidgetDefinition).queryMatcher;
const matchesWidgetQuery = useCallback(
(queryKey: QueryKey) => widgetQueryKeys.some((candidate) => partialMatchKey(queryKey, candidate)),
[widgetQueryKeys],
(queryKey: QueryKey) =>
matchesWidgetItemQuery(
queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options,
runtimeQueries: getWidgetRuntimeQueries(widgetStateRef),
},
widgetQueryMatcher,
),
[board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetStateRef],
);
const isWidgetFetching =
useIsFetching({
@@ -84,17 +122,29 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
predicate: (query) => matchesWidgetQuery(query.queryKey),
}) > 0;
const handleRefetch = useCallback(() => {
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "active" })));
}, [queryClient, widgetQueryKeys]);
void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) });
}, [matchesWidgetQuery, queryClient]);
const canInteractWithSelectedIntegrations = useMemo(() => {
const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id));
return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id));
}, [integrationsWithInteractAccess, item.integrationIds]);
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
const setItemOptions = useCallback(
(partial: Record<string, unknown>) => {
if (isEditMode) {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
return;
}
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem,
),
}));
},
[isEditMode, item.id, options, persistBoard, updateItemOptions],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
@@ -110,9 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
setOptions: setItemOptions,
integrationIds: item.integrationIds,
context: {
isEditMode,
@@ -125,20 +173,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
})();
const persistBoard = useCallback(
(updater: (previous: typeof board) => typeof board) => {
updateAndPersistBoard(updater, {
onError: () => {
showErrorNotification({
title: t("item.menu.notification.saveError.title"),
message: t("item.menu.notification.saveError.message"),
});
},
});
},
[t, updateAndPersistBoard],
);
const openEditModal = useCallback(() => {
openModal(
{
@@ -195,19 +229,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
if (isEditMode) {
updateItemOptions({ itemId: item.id, newOptions: { ...options, [key]: checked } });
} else {
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id !== item.id ? boardItem : { ...boardItem, options: { ...boardItem.options, [key]: checked } },
),
}));
}
},
[options, item.id, updateItemOptions, isEditMode, persistBoard],
(key: string) => (checked: boolean) => setItemOptions({ [key]: checked }),
[setItemOptions],
);
if (!session) return <>{children}</>;
@@ -306,7 +329,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
{tMenu("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
queryKeys={widgetQueryKeys}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
@@ -350,15 +373,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }:
interface WidgetQueryStatusProps {
queryClient: QueryClient;
queryKeys: readonly QueryKey[];
matchesQuery: (queryKey: QueryKey) => boolean;
isFetching: boolean;
t: TranslationFunction;
}
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => {
const queries = queryClient.getQueryCache().findAll({
type: "active",
predicate: (query) => queryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
predicate: (query) => matchesQuery(query.queryKey),
});
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
@@ -0,0 +1,439 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import { matchesWidgetItemQuery } from "./widget-query-scope";
type WidgetKind = keyof typeof widgetImports;
const allPhotosAlbumId = "all";
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
itemId: "item-1",
boardId: "board-1",
integrationIds: ["integration-1"],
options: {},
runtimeQueries: [],
...overrides,
});
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
const queryKey: QueryKey = [path, { input, type: "query" }];
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
};
describe("matchesWidgetItemQuery", () => {
test("matches only the current Custom API definition", () => {
const scope = createScope({ options: { definitionId: "definition-1" } });
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-1" }, scope)).toBe(
true,
);
expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-2" }, scope)).toBe(
false,
);
});
test("owns only the active compact or advanced Media Missing page size", () => {
const path = ["widget", "mediaOrganizer", "getData"];
const compactInput = { integrationIds: ["radarr-1"], pageSize: 20 };
const advancedInput = { integrationIds: ["radarr-1"], pageSize: 50 };
const compactScope = createScope({
integrationIds: ["radarr-1"],
runtimeQueries: [{ path, input: compactInput }],
});
const advancedScope = createScope({
integrationIds: ["radarr-1"],
runtimeQueries: [{ path, input: advancedInput }],
});
expect(matches("mediaMissing", path, compactInput, compactScope)).toBe(true);
expect(matches("mediaMissing", path, advancedInput, compactScope)).toBe(false);
expect(matches("mediaMissing", path, advancedInput, advancedScope)).toBe(true);
expect(matches("mediaMissing", path, compactInput, advancedScope)).toBe(false);
});
test("owns every Releases batch by item id", () => {
const scope = createScope({ itemId: "release-item" });
const path = ["widget", "releases", "getLatest"];
expect(matches("releases", path, { itemId: "release-item", repositories: [{ id: "one" }] }, scope)).toBe(true);
expect(matches("releases", path, { itemId: "sibling-item", repositories: [{ id: "one" }] }, scope)).toBe(false);
});
test("normalizes Immich's all-photos sentinel and separates Immich widget kinds", () => {
const carouselScope = createScope({
integrationIds: ["immich-1"],
options: { albumId: allPhotosAlbumId },
});
const serverStatsQuery: NormalizedWidgetQuery = {
path: ["widget", "immich", "getServerStats"],
input: { integrationId: "immich-1" },
};
const albumsQuery: NormalizedWidgetQuery = {
path: ["widget", "immich", "getAlbums"],
input: { integrationId: "immich-1" },
};
const compactServerScope = createScope({
integrationIds: ["immich-1"],
runtimeQueries: [serverStatsQuery],
});
const advancedServerScope = createScope({
integrationIds: ["immich-1"],
runtimeQueries: [serverStatsQuery, albumsQuery],
});
expect(
matches("immich-albumCarousel", ["widget", "immich", "getAlbum"], { integrationId: "immich-1" }, carouselScope),
).toBe(true);
expect(
matches(
"immich-albumCarousel",
["widget", "immich", "getAlbum"],
{ integrationId: "immich-1", albumId: "album-2" },
carouselScope,
),
).toBe(false);
expect(
matches(
"immich-albumCarousel",
["widget", "immich", "getServerStats"],
{ integrationId: "immich-1" },
carouselScope,
),
).toBe(false);
expect(matches("immich-serverStats", serverStatsQuery.path, serverStatsQuery.input, compactServerScope)).toBe(true);
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, compactServerScope)).toBe(false);
expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, advancedServerScope)).toBe(true);
expect(
matches("immich-serverStats", ["widget", "immich", "getAlbum"], albumsQuery.input, advancedServerScope),
).toBe(false);
});
test("uses the resolved Beszel integration, system, and includeDocker input", () => {
const runtimeQuery: NormalizedWidgetQuery = {
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: ["beszel-2"],
systemId: "system-2",
timePeriod: "1h",
includeDocker: true,
},
};
const scope = createScope({
integrationIds: ["beszel-1", "beszel-2"],
options: { systemId: "beszel-2:system-2", timePeriod: "1h", showDockerCpu: true },
runtimeQueries: [runtimeQuery],
});
expect(matches("beszelSystemStats", runtimeQuery.path, runtimeQuery.input, scope)).toBe(true);
expect(
matches(
"beszelSystemStats",
runtimeQuery.path,
{ ...(runtimeQuery.input as Record<string, unknown>), includeDocker: false },
scope,
),
).toBe(false);
expect(
matches(
"beszelSystemStats",
runtimeQuery.path,
{ ...(runtimeQuery.input as Record<string, unknown>), systemId: "system-1" },
scope,
),
).toBe(false);
});
test("separates Beszel procedure families while retaining shared getSystems ownership", () => {
const scope = createScope({
integrationIds: ["beszel-1"],
options: { showHistory: true, maxHistoryItems: 10 },
});
const systemsInput = { integrationIds: ["beszel-1"] };
const alertsInput = { integrationIds: ["beszel-1"], includeHistory: true, maxHistoryItems: 10 };
expect(matches("beszelSystemGrid", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
expect(matches("beszelSystemTable", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
expect(matches("beszelAlerts", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(false);
expect(matches("beszelAlerts", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(true);
expect(matches("beszelSystemGrid", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(false);
});
test("owns only the active Beszel table detail query", () => {
const activeQuery: NormalizedWidgetQuery = {
path: ["widget", "beszel", "getSystemStats"],
input: {
integrationIds: ["beszel-1"],
systemId: "system-1",
timePeriod: "1h",
includeDocker: false,
},
};
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
expect(matches("beszelSystemTable", activeQuery.path, activeQuery.input, scope)).toBe(true);
expect(
matches(
"beszelSystemTable",
activeQuery.path,
{ ...(activeQuery.input as Record<string, unknown>), systemId: "system-2" },
scope,
),
).toBe(false);
expect(matches("beszelSystemGrid", activeQuery.path, activeQuery.input, scope)).toBe(false);
});
test("owns only Umami queries registered for the active view and layout", () => {
const eventQuery: NormalizedWidgetQuery = {
path: ["widget", "umami", "getMultiEventTimeSeries"],
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["signup", "view"] },
};
const topPagesQuery: NormalizedWidgetQuery = {
path: ["widget", "umami", "getTopPages"],
input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", limit: 5 },
};
const eventsScope = createScope({ runtimeQueries: [eventQuery] });
const advancedScope = createScope({ runtimeQueries: [eventQuery, topPagesQuery] });
expect(matches("umami", eventQuery.path, eventQuery.input, eventsScope)).toBe(true);
expect(
matches(
"umami",
eventQuery.path,
{ integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["view", "signup"] },
eventsScope,
),
).toBe(false);
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, eventsScope)).toBe(false);
expect(matches("umami", topPagesQuery.path, topPagesQuery.input, advancedScope)).toBe(true);
});
test("maps Media Request status filters and separates list from stats", () => {
const listScope = createScope({
integrationIds: ["request-1"],
options: { statusFilter: [], recentDays: 30, linksTargetNewTab: true },
});
const allStatuses = ["pending", "approved", "declined", "failed", "completed"];
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getLatestRequests"],
{ integrationIds: ["request-1"], statuses: allStatuses, recentDays: 30 },
listScope,
),
).toBe(true);
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getLatestRequests"],
{ integrationIds: ["request-1"], statuses: ["pending"], recentDays: 30 },
listScope,
),
).toBe(false);
expect(
matches(
"mediaRequests-requestList",
["widget", "mediaRequests", "getStats"],
{ integrationIds: ["request-1"] },
listScope,
),
).toBe(false);
expect(
matches(
"mediaRequests-requestStats",
["widget", "mediaRequests", "getStats"],
{ integrationIds: ["request-1"] },
listScope,
),
).toBe(true);
});
test("separates Audio Stats stream details from Media Server filters", () => {
const statsQuery: NormalizedWidgetQuery = {
path: ["widget", "audioStats", "getStats"],
input: { integrationId: "audio-1" },
};
const streamsQuery: NormalizedWidgetQuery = {
path: ["widget", "mediaServer", "getCurrentStreams"],
input: { integrationIds: ["audio-1"], showOnlyPlaying: false },
};
const compactAudioScope = createScope({ integrationIds: ["audio-1"], runtimeQueries: [statsQuery] });
const advancedAudioScope = createScope({
integrationIds: ["audio-1"],
runtimeQueries: [statsQuery, streamsQuery],
});
const mediaScope = createScope({ integrationIds: ["audio-1"], options: { showOnlyPlaying: true } });
expect(matches("audioStats", statsQuery.path, statsQuery.input, compactAudioScope)).toBe(true);
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, compactAudioScope)).toBe(false);
expect(matches("audioStats", streamsQuery.path, streamsQuery.input, advancedAudioScope)).toBe(true);
expect(
matches(
"audioStats",
streamsQuery.path,
{ integrationIds: ["audio-1"], showOnlyPlaying: true },
advancedAudioScope,
),
).toBe(false);
expect(
matches("mediaServer", streamsQuery.path, { integrationIds: ["audio-1"], showOnlyPlaying: true }, mediaScope),
).toBe(true);
expect(matches("mediaServer", streamsQuery.path, streamsQuery.input, mediaScope)).toBe(false);
});
test("uses each Calendar instance's registered month", () => {
const path = ["widget", "calendar", "findAllEvents"];
const januaryInput = {
integrationIds: ["calendar-1"],
month: 0,
year: 2026,
releaseType: [],
showUnmonitored: false,
};
const februaryInput = { ...januaryInput, month: 1 };
const januaryScope = createScope({ runtimeQueries: [{ path, input: januaryInput }] });
const februaryScope = createScope({ runtimeQueries: [{ path, input: februaryInput }] });
expect(matches("calendar", path, januaryInput, januaryScope)).toBe(true);
expect(matches("calendar", path, februaryInput, januaryScope)).toBe(false);
expect(matches("calendar", path, februaryInput, februaryScope)).toBe(true);
});
test("uses each Media Transcoding instance's registered page and geometry-derived page size", () => {
const path = ["widget", "mediaTranscoding", "getDataAsync"];
const firstPage = { integrationId: "transcode-1", pageSize: 8, page: 1 };
const secondPage = { integrationId: "transcode-1", pageSize: 12, page: 2 };
const firstScope = createScope({ runtimeQueries: [{ path, input: firstPage }] });
const secondScope = createScope({ runtimeQueries: [{ path, input: secondPage }] });
expect(matches("mediaTranscoding", path, firstPage, firstScope)).toBe(true);
expect(matches("mediaTranscoding", path, secondPage, firstScope)).toBe(false);
expect(matches("mediaTranscoding", path, secondPage, secondScope)).toBe(true);
});
test("matches only the Downloads query with the current integrations and limit", () => {
const scope = createScope({
integrationIds: ["sabnzbd-1", "qbittorrent-1"],
options: { limitPerIntegration: 25 },
});
const path = ["widget", "downloads", "getJobsAndStatuses"];
expect(
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 25 }, scope),
).toBe(true);
expect(matches("downloads", path, { integrationIds: ["sabnzbd-1"], limitPerIntegration: 25 }, scope)).toBe(false);
expect(
matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 50 }, scope),
).toBe(false);
expect(
matches("downloads", ["widget", "downloads", "pause"], { integrationIds: ["sabnzbd-1", "qbittorrent-1"] }, scope),
).toBe(false);
});
test("matches only the active Stock Price symbol and time frame", () => {
const scope = createScope({ options: { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" } });
const path = ["widget", "stockPrice", "getPriceHistory"];
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(true);
expect(matches("stockPrice", path, { stock: "MSFT", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(false);
expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "5d", timeInterval: "1d" }, scope)).toBe(false);
});
test("matches Weather by the coordinates sent to the location procedure", () => {
const scope = createScope({
options: { location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 } },
});
const path = ["widget", "weather", "atLocation"];
expect(matches("weather", path, { latitude: 48.85341, longitude: 2.3488 }, scope)).toBe(true);
expect(matches("weather", path, { latitude: 51.5072, longitude: -0.1276 }, scope)).toBe(false);
expect(
matches("weather", ["widget", "weather", "forecast"], { latitude: 48.85341, longitude: 2.3488 }, scope),
).toBe(false);
});
test("matches Clock weather only when enabled and at its configured coordinates", () => {
const path = ["widget", "weather", "atLocation"];
const location = { name: "Brisbane", latitude: -27.4698, longitude: 153.0251 };
const enabledScope = createScope({ options: { showWeather: true, weatherLocation: location } });
const disabledScope = createScope({ options: { showWeather: false, weatherLocation: location } });
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, enabledScope)).toBe(true);
expect(matches("clock", path, { latitude: 48.85341, longitude: 2.3488 }, enabledScope)).toBe(false);
expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, disabledScope)).toBe(false);
});
test("matches Minecraft Server Status by the full input passed to useQuery", () => {
const scope = createScope({
options: { title: "Survival", domain: "play.example.com", isBedrockServer: false },
});
const path = ["widget", "minecraft", "getServerStatus"];
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Survival", domain: "play.example.com", isBedrockServer: false },
scope,
),
).toBe(true);
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Creative", domain: "play.example.com", isBedrockServer: false },
scope,
),
).toBe(false);
expect(
matches(
"minecraftServerStatus",
path,
{ title: "Survival", domain: "bedrock.example.com", isBedrockServer: true },
scope,
),
).toBe(false);
});
test("matches Notifications by integrations and the full input passed to useQuery", () => {
const scope = createScope({ integrationIds: ["ntfy-1"], options: { hideLogos: true } });
const path = ["widget", "notifications", "getNotifications"];
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["ntfy-1"] }, scope)).toBe(true);
expect(matches("notifications", path, { hideLogos: false, integrationIds: ["ntfy-1"] }, scope)).toBe(false);
expect(matches("notifications", path, { hideLogos: true, integrationIds: ["gotify-1"] }, scope)).toBe(false);
expect(
matches("notifications", ["widget", "notifications", "dismiss"], { integrationIds: ["ntfy-1"] }, scope),
).toBe(false);
});
test("keeps renamed RSS input fields item-specific", () => {
const scope = createScope({
integrationIds: [],
options: { feedUrls: ["https://feed.example/rss"], maximumAmountPosts: 100 },
});
const path = ["widget", "rssFeed", "getFeeds"];
expect(matches("rssFeed", path, { urls: ["https://feed.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(true);
expect(matches("rssFeed", path, { urls: ["https://other.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(
false,
);
});
test("generic fallback ignores transformed non-identity inputs", () => {
const scope = createScope({ integrationIds: ["integration-1"], options: { pageSize: 10 } });
const definitionKeys = [[["widget", "example"]]] as const;
expect(
matchesWidgetItemQuery(
[["widget", "example", "getData"], { input: { integrationIds: ["integration-1"], pageSize: 50 } }],
definitionKeys,
scope,
),
).toBe(true);
});
});
@@ -0,0 +1,94 @@
import type { QueryKey } from "@tanstack/react-query";
import { hashKey } from "@tanstack/react-query";
import type { NormalizedWidgetQuery, WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets";
export const matchesWidgetItemQuery = (
queryKey: QueryKey,
widgetQueryKeys: readonly QueryKey[],
scope: WidgetQueryMatcherScope,
queryMatcher?: WidgetQueryMatcher,
) => {
const query = normalizeWidgetQuery(queryKey);
if (!query || !widgetQueryKeys.some((candidate) => matchesDefinitionPath(query.path, candidate))) return false;
if (queryMatcher) return queryMatcher(query, scope);
return matchesQueryInput(query.input, scope);
};
const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
const path = queryKey[0];
if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
const queryKeyOptions = queryKey[1];
const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
return { path, input };
};
const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => {
const candidatePath = candidate[0];
return (
Array.isArray(candidatePath) &&
candidatePath.every((part, index) => typeof part === "string" && path[index] === part)
);
};
const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => {
if (input == null) return true;
if (Array.isArray(input)) {
return isEqual(input, scope.integrationIds) || containsValue(scope.options, input);
}
if (!isRecord(input)) {
return (
input === scope.itemId ||
input === scope.boardId ||
scope.integrationIds.includes(String(input)) ||
containsValue(scope.options, input)
);
}
for (const [key, value] of Object.entries(input)) {
if (key === "itemId") {
if (value !== scope.itemId) return false;
continue;
}
if (key === "boardId") {
if (value !== scope.boardId) return false;
continue;
}
if (key === "integrationId") {
if (!scope.integrationIds.includes(String(value))) return false;
continue;
}
if (key === "integrationIds") {
if (!isEqual(value, scope.integrationIds)) return false;
continue;
}
if (isIdentityKey(key) && !matchesAnyScopedValue(value, scope)) return false;
}
return true;
};
const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) =>
value === scope.itemId ||
value === scope.boardId ||
scope.integrationIds.includes(String(value)) ||
containsValue(scope.options, value);
const isIdentityKey = (key: string) => key === "id" || key.endsWith("Id") || key.endsWith("Ids");
const containsValue = (container: unknown, expected: unknown, seen = new WeakSet<object>()): boolean => {
if (isEqual(container, expected)) return true;
if (container === null || typeof container !== "object" || seen.has(container)) return false;
seen.add(container);
return Object.values(container).some((value) => containsValue(value, expected, seen));
};
const isEqual = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
@@ -0,0 +1,4 @@
.suppressTouchCallout {
user-select: none;
-webkit-touch-callout: none;
}
@@ -17,6 +17,10 @@ import { ItemSelectModal } from "../../items/item-select-modal";
import { useBoardPermissions } from "../../permissions/client";
import type { BoardPlacementHint } from "../../items/actions/create-item";
import type { UseGridstackRefs } from "./use-gridstack";
import classes from "./board-empty-space-context-menu.module.css";
import { hasExceededLongPressMoveTolerance } from "./long-press";
const suppressTouchCalloutClass = classes.suppressTouchCallout as string;
interface MenuState {
x: number;
@@ -39,18 +43,25 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const [isEditMode] = useEditMode();
const [menu, setMenu] = useState<MenuState | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null);
const touchGuardElement = useRef<HTMLDivElement | null>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
const clearLongPress = () => {
if (longPressTimer.current === null) return;
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
if (longPressTimer.current !== null) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
touchGuardElement.current = null;
longPressPointer.current = null;
};
useEffect(
() => () => {
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
},
[],
);
@@ -89,12 +100,31 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
clearLongPress();
longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
touchGuardElement.current = event.currentTarget;
touchGuardElement.current.classList.add(suppressTouchCalloutClass);
longPressTimer.current = setTimeout(() => {
setMenu({ x: event.clientX, y: event.clientY, placement });
navigator.vibrate?.(20);
}, 500);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
const pointer = longPressPointer.current;
if (
!pointer ||
pointer.pointerId !== event.pointerId ||
!hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY })
) {
return;
}
clearLongPress();
};
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
!enabled ||
@@ -134,10 +164,10 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={clearLongPress}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
onPointerLeave={clearLongPress}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onPointerLeave={handlePointerEnd}
>
{children}
</Box>
@@ -0,0 +1,17 @@
import { describe, expect, test } from "vitest";
import { hasExceededLongPressMoveTolerance } from "./long-press";
describe("hasExceededLongPressMoveTolerance", () => {
test("allows small touch jitter", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 106 })).toBe(false);
});
test("allows movement exactly at the threshold", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 108 })).toBe(false);
});
test("cancels a deliberate scroll gesture", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 100, y: 116 })).toBe(true);
});
});
@@ -0,0 +1,7 @@
export const longPressMoveTolerancePx = 10;
export const hasExceededLongPressMoveTolerance = (
start: { x: number; y: number },
current: { x: number; y: number },
tolerance = longPressMoveTolerancePx,
) => Math.hypot(current.x - start.x, current.y - start.y) > tolerance;