Compare commits

..
Author SHA1 Message Date
Thomas Camlong f071cba70d test: cover public custom widgets for guests 2026-08-11 15:00:40 +02:00
Thomas 827ba7cda4 feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-11 14:51:31 +02:00
Thomas Camlong 44c22ccad3 ci: retry flaky container-backed unit tests in fast gate
Integration tests (nzbget, nextcloud, pi-hole, etc.) start Docker
containers and intermittently time out under shared-runner load, which
fails the fast gate and skips the container job that publishes the PR
preview image. Retry failing unit tests twice so transient container
startup failures do not block the image publish.
2026-08-11 11:03:33 +02:00
Thomas Camlong eae17877a1 ci: make e2e tests resilient to CI resource contention
E2E specs spin up Docker containers in parallel and intermittently time
out waiting for UI elements under shared-runner load. Limit parallel
workers to 2 and retry flaky failures twice so the container job can
complete and publish the PR preview image.
2026-08-11 11:03:12 +02:00
ajnart 6435955a51 test: wait for Home Assistant API readiness 2026-08-11 11:02:43 +02:00
Thomas Camlong 7a45b0d119 feat(workshop): add social previews 2026-08-10 20:07:51 +02:00
Thomas Camlong 8b49982c3a fix: persist Workshop PocketBase data 2026-08-10 19:47:29 +02:00
Thomas Camlong ad2fa1605e fix: defer Spotlight without loading feedback 2026-08-10 19:16:16 +02:00
Thomas Camlong a6061348c3 fix: polish settings and command menu 2026-08-10 19:00:43 +02:00
Thomas Camlong e524515ca5 fix(workshop): streamline import review 2026-08-10 18:47:23 +02:00
Thomas Camlong cf3c4b4152 fix(workshop): stack widget install flow 2026-08-10 18:19:31 +02:00
Thomas Camlong 378bc0cd28 fix(workshop): expose moderation 2026-08-10 17:37:13 +02:00
Thomas Camlong be93fb330a fix: simplify custom widget preview controls 2026-08-10 17:22:40 +02:00
Thomas Camlong 893fcd6ed1 fix(workshop): use PocketBase name 2026-08-10 16:24:52 +02:00
Thomas Camlong 0422675bc1 fix(workshop): use GitHub handles in Homarr 2026-08-10 15:32:32 +02:00
Thomas Camlong d610206896 fix(docs): exclude generated build from typecheck 2026-08-10 15:21:47 +02:00
Thomas Camlong 64403079d8 fix(workshop): simplify GitHub identities 2026-08-10 15:14:32 +02:00
Thomas Camlong 015e0cbf63 Merge dev into v2 2026-08-10 14:23:19 +02:00
Thomas Camlong 52266fc7f6 docs: update WUD OpenAPI schema 2026-08-10 10:53:30 +02:00
Thomas Camlong d53021a0f3 Merge remote-tracking branch 'origin/dev' into feat/refresh-latest-dev-v2 2026-08-10 10:44:22 +02:00
Thomas Camlong c7e6ed8943 test: validate Workshop source locale 2026-08-10 10:15:58 +02:00
Thomas Camlong 5efe053c0f merge dev into v2 2026-08-10 10:06:18 +02:00
Thomas Camlong 1b489cc2e0 fix: stabilize v2 CI 2026-08-09 22:27:28 +02:00
Thomas Camlong 7cf27c3f39 feat: add Workshop staging deployment 2026-08-09 21:40:20 +02:00
Thomas Camlong 8a3f8d5c12 fix: allow Workshop runtime config writes 2026-08-09 21:21:01 +02:00
Thomas Camlong 1028752cbc fix: restore Workshop runtime URLs 2026-08-09 20:11:13 +02:00
Thomas Camlong b9c89c61a2 test: harden integration icon checks 2026-08-09 13:39:16 +02:00
Thomas Camlong b5dbeae864 test: fix custom option repair fixture 2026-08-09 09:21:55 +02:00
Thomas Camlong d513c7fe46 fix: repair stale custom widget options 2026-08-09 09:17:32 +02:00
Thomas Camlong 8dad83f720 test(e2e): serialize container specs 2026-08-09 02:55:51 +02:00
Thomas Camlong bbc23a6c65 fix(settings): avoid nested forms 2026-08-09 02:21:01 +02:00
Thomas Camlong 5ee14136ac fix(clock): defer live time until mount 2026-08-09 01:46:30 +02:00
Thomas Camlong 9d3d40d4bd fix(e2e): stabilize initial board hydration 2026-08-09 01:11:38 +02:00
Thomas Camlong 48d8517ed1 fix(clock): suppress live timestamp hydration noise 2026-08-09 00:37:33 +02:00
Thomas Camlong 7619492516 test(v2): make board tour dismissal optional 2026-08-09 00:14:10 +02:00
Thomas Camlong ca6a019c1e test(v2): stabilize production E2E flows 2026-08-09 00:10:49 +02:00
Thomas Camlong cae976b6d8 fix(ci): exclude e2e project from unit gate 2026-08-08 23:06:42 +02:00
Thomas Camlong 215f9a7d21 fix(ci): exclude e2e tests from unit gate 2026-08-08 22:50:57 +02:00
Thomas Camlong dec46f3ee4 fix(ci): detect custom widget runtime chunks 2026-08-08 22:38:42 +02:00
Thomas Camlong aaf86a9d13 fix(ci): refresh v2 generated artifacts 2026-08-08 22:15:24 +02:00
Thomas Camlong 2efc225617 fix(ci): align Workshop pnpm version 2026-08-08 21:52:01 +02:00
Thomas Camlong dd63d5600b merge dev into v2 2026-08-08 19:54:57 +02:00
ajnart 25be53244b ci(workshop): restore image publishing 2026-08-08 19:25:35 +02:00
Thomas 8ab3f136b8 fix(workshop): harden deployment and deep links (#6542) 2026-08-08 17:41:54 +02:00
Thomas 557ebe0a2f fix: harden v2 custom widgets and Workshop (#6480) 2026-07-31 13:28:53 +02:00
Thomas e8a2c87c2d Delete review.md 2026-07-23 02:13:20 +02:00
Thomas 4f6e335f72 fix(custom-widgets): address final v2 review feedback (#6451) 2026-07-23 02:11:32 +02:00
Thomas df967751f6 fix(custom-widgets): address v2 merge review (#6446) 2026-07-23 01:55:15 +02:00
Thomas 6aceb2fd1f feat(custom-widgets): complete Custom JSX v2 (#6390) 2026-07-22 20:47:19 +02:00
Thomas 60cbd7bb7a Merge branch 'dev' into v2 2026-07-18 23:00:30 +02:00
Thomas c0f1d6624c fix(custom-widgets): fix v2 editor crash and definition-not-found race (#6377) 2026-07-17 14:39:29 +02:00
Thomas Camlong 67d146220a test(integrations): wait for Home Assistant restart 2026-07-16 16:25:48 +02:00
Thomas Camlong 41db737905 ci: publish multi-platform preview images 2026-07-16 16:16:01 +02:00
Thomas Camlong 3b128585a3 ci: remove memory regression test 2026-07-16 15:01:27 +02:00
Thomas Camlong cdaf2a838c fix(ci): preserve memory-test encryption key 2026-07-16 14:52:40 +02:00
Thomas Camlong 926a6856fb test(integrations): allow recurring calendar setup 2026-07-16 14:34:07 +02:00
Thomas Camlong c6160da222 fix(ci): stabilize affected workspace gate 2026-07-16 14:22:01 +02:00
Thomas Camlong e729dfd891 fix(ci): prevent autofix race 2026-07-16 14:13:39 +02:00
Thomas Camlong eb71f936c6 fix(custom-widgets): remove stale form helpers 2026-07-16 14:13:39 +02:00
ajnart c10ebbc863 style: auto-fix lint and format [skip ci] 2026-07-16 08:22:51 +00:00
Thomas 106c3bfa90 Merge branch 'dev' into v2 2026-07-16 10:21:40 +02:00
Thomas Camlong 27a6467896 fix(ci): don't cancel CI on autofix pushes, guard format step 2026-07-15 16:47:28 +02:00
ajnart 8331a58471 style: auto-fix lint and format [skip ci] 2026-07-15 14:20:37 +00:00
Thomas Camlong ec74c36a6a fix(oxlint): remove unsupported option from docs child config 2026-07-15 16:19:12 +02:00
Thomas Camlong 487d98275c fix(ci): autofix commits skip CI to prevent race cancellation 2026-07-15 16:17:59 +02:00
Thomas Camlong 187a479bd5 fix(ci): scope format check to only PR-changed files 2026-07-15 15:57:42 +02:00
Thomas Camlong 0bfe6ddabe fix(ci): don't fail fast-gate on format check (auto-fix handles it) 2026-07-15 15:55:00 +02:00
ajnart 51d8a3cc53 style: auto-fix lint and format 2026-07-15 13:54:18 +00:00
Thomas Camlong 06db42129c fix(ci): remove coverage step, fix auto-fix to format all affected workspaces 2026-07-15 15:47:11 +02:00
Thomas Camlong a11583feb1 Revert "style: format all files and remove coverage report step"
This reverts commit 446931bc0a.
2026-07-15 15:44:49 +02:00
Thomas Camlong 446931bc0a style: format all files and remove coverage report step 2026-07-15 15:42:13 +02:00
Thomas Camlong 34bcc62415 fix: disable cpu-features native build (incompatible with Node 24 V8 API) 2026-07-15 15:35:43 +02:00
Thomas Camlong a2e3c5d3bc style: fix formatting in v2 files 2026-07-15 15:34:15 +02:00
Thomas Camlong ed9646b0c6 fix: regenerate pnpm-lock.yaml after dev merge 2026-07-15 15:25:52 +02:00
Thomas Camlong e0de370340 Merge branch 'dev' into v2 2026-07-15 15:17:18 +02:00
homarr-renovate[bot] 7e5453d47c chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.7 (#6343)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 12:16:38 +00:00
Thomas 3b2fc2527e feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304) 2026-07-15 14:06:02 +02:00
Thomasandhomarr-renovate[bot] 0a0b3a12c9 bring dev changes to v2 (#6353)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-15 12:16:16 +02:00
Thomas ba263dbe47 Delete .opencode directory 2026-07-15 12:14:46 +02:00
Thomas Camlong 8a7c4875bf Revert "chore(merge): resolve v2 conflicts"
This reverts commit 377f2f6a20, reversing
changes made to 7e4cb0201b.
2026-07-15 12:12:33 +02:00
Thomas Camlong 377f2f6a20 chore(merge): resolve v2 conflicts 2026-07-15 12:09:51 +02:00
Thomasandcopilot-swe-agent[bot] c69e33ac8c feat: community workshop for custom widgets and CSS (#5984)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-15 11:53:15 +02:00
Thomas Camlong 121a83040d Setup v2 pre-release branch 2026-07-13 18:14:19 +02:00
26 changed files with 201 additions and 322 deletions

No files matched your search

@@ -10,7 +10,7 @@ export const wudWidget: WidgetDefinition = {
items: [
{
name: "Show title",
description: 'Displays the icon and "What\'s Up Docker" label above the stats',
description: "Displays the icon and \"What's Up Docker\" label above the stats",
values: { type: "boolean" },
defaultValue: "yes",
},
@@ -28,8 +28,7 @@ export const wudWidget: WidgetDefinition = {
},
{
name: "Show container update list",
description:
"Lists each monitored container that has an image update available, linking to its release notes when known",
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
values: { type: "boolean" },
defaultValue: "no",
},
@@ -145,7 +145,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
key={id}
component="a"
href={`#${id}`}
onClick={() => setMobilePane(id === "preview" ? "preview" : "configure")}
size="compact-sm"
variant="subtle"
color={invalidSections.has(id) ? "red" : undefined}
@@ -22,7 +22,6 @@ vi.mock("@homarr/api/client", () => ({
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
const rendererAttempt = vi.hoisted(() => vi.fn());
const codeEditorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
default: ({ data }: { data: Record<string, unknown> }) => {
@@ -33,12 +32,7 @@ vi.mock("@homarr/widgets/custom-api/custom-jsx-display", () => ({
return <div>widget-renderer</div>;
},
}));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { label: string; readOnly?: boolean }) => {
codeEditorProps(props);
return <div>{props.label}</div>;
},
}));
vi.mock("./_code-editor", () => ({ CodeEditor: ({ label }: { label: string }) => <div>{label}</div> }));
vi.mock("./_custom-widget-preview-action", () => ({ PreviewActionControl: () => <div>action-control</div> }));
let root: Root;
@@ -115,7 +109,6 @@ describe("Custom Widget preview panel", () => {
);
await act(async () => dataTab?.click());
expect(host.textContent).toContain("requestData");
expect(codeEditorProps).toHaveBeenCalledWith(expect.objectContaining({ label: "requestData", readOnly: true }));
expect(host.textContent).not.toContain("invalid");
const optionsTab = [...host.querySelectorAll<HTMLElement>('[role="tab"]')].find((element) =>
@@ -172,7 +172,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("requestData")}
language="json"
value={JSON.stringify({ data: props.preview.data, status: props.preview.status }, null, 2)}
readOnly
onChange={() => undefined}
/>
</Tabs.Panel>
@@ -252,7 +251,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
label={t("journal")}
language="json"
value={JSON.stringify(journalQuery.data ?? [], null, 2)}
readOnly
onChange={() => undefined}
/>
)}
@@ -87,9 +87,7 @@ export function CustomWidgetSourcesEditor({
form.setFieldValue("secrets", [...current, { sourceId, kind, value, hasValue: existing?.hasValue }]);
};
const addSource = () => {
let suffix = sources.length + 1;
while (sources.some(({ id }) => id === `source-${suffix}`)) suffix += 1;
const id = sources.length === 0 ? "default" : `source-${suffix}`;
const id = sources.length === 0 ? "default" : `source-${sources.length + 1}`;
form.setFieldValue(
"sources",
JSON.stringify(
@@ -1,46 +0,0 @@
// @vitest-environment jsdom
import { act } 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 { JsonPreviewEditor } from "./_json-preview-editor";
const editorProps = vi.hoisted(() => vi.fn());
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
vi.mock("./_code-editor", () => ({
CodeEditor: (props: { error?: string; onChange(value: string): void }) => {
editorProps(props);
return <button onClick={() => props.onChange("{invalid")}>edit</button>;
},
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
describe("preview JSON editor", () => {
it("surfaces invalid JSON and clears the previously rendered options", async () => {
const onChange = vi.fn();
await act(async () => {
root.render(<JsonPreviewEditor id="options" label="Options" value={{ stale: true }} onChange={onChange} />);
});
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
expect(onChange).toHaveBeenCalledWith({});
expect(editorProps).toHaveBeenLastCalledWith(expect.objectContaining({ error: "invalidJson" }));
});
});
@@ -2,8 +2,6 @@
import { useEffect, useState } from "react";
import { useScopedI18n } from "@homarr/translation/client";
import { CodeEditor } from "./_code-editor";
import { isRecord, parseJson } from "./_custom-widget-form-utils";
@@ -18,26 +16,19 @@ export function JsonPreviewEditor({
value: Record<string, unknown>;
onChange(value: Record<string, unknown>): void;
}) {
const t = useScopedI18n("customWidget.workbench.builder");
const serialized = JSON.stringify(value, null, 2);
const [draft, setDraft] = useState(serialized);
const [invalid, setInvalid] = useState(false);
useEffect(() => {
if (!invalid) setDraft(serialized);
}, [invalid, serialized]);
useEffect(() => setDraft(serialized), [serialized]);
return (
<CodeEditor
id={id}
label={label}
language="json"
value={draft}
error={invalid ? t("invalidJson") : undefined}
onChange={(next) => {
setDraft(next);
const parsed = parseJson(next);
const valid = isRecord(parsed);
setInvalid(!valid);
onChange(valid ? parsed : {});
if (isRecord(parsed)) onChange(parsed);
}}
/>
);
@@ -48,17 +48,4 @@ describe("custom widget unsaved changes guard", () => {
expect(listLink.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }))).toBe(true);
expect(confirmNavigation).not.toHaveBeenCalled();
});
it("restores the current page and confirms browser history navigation", () => {
window.history.replaceState(null, "", "/en/manage/custom-widgets");
window.history.pushState(null, "", "/en/manage/custom-widgets/edit/widget-1");
const confirmNavigation = vi.fn();
cleanups.push(registerUnsavedChangesGuard({ isDirty: () => true, confirmNavigation }));
window.history.replaceState(null, "", "/en/manage/custom-widgets");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(window.location.pathname).toBe("/en/manage/custom-widgets/edit/widget-1");
expect(confirmNavigation).toHaveBeenCalledWith("/en/manage/custom-widgets");
});
});
@@ -4,7 +4,6 @@ interface UnsavedChangesGuardOptions {
}
export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: UnsavedChangesGuardOptions) {
let currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const handleClick = (event: MouseEvent) => {
if (!isDirty() || event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -33,22 +32,11 @@ export function registerUnsavedChangesGuard({ isDirty, confirmNavigation }: Unsa
event.preventDefault();
event.returnValue = true;
};
const handlePopState = () => {
const destination = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (!isDirty()) {
currentHref = destination;
return;
}
window.history.pushState(window.history.state, "", currentHref);
confirmNavigation(destination);
};
document.addEventListener("click", handleClick, true);
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
return () => {
document.removeEventListener("click", handleClick, true);
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
};
}
@@ -1,17 +1,38 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
@@ -20,6 +41,8 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -43,14 +66,60 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
</UnstyledButton>
</UserAvatarMenu>
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
);
};
@@ -21,9 +21,17 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
}
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const session = useSession();
@@ -40,7 +48,7 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }:
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted>
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
@@ -295,15 +295,19 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
<Stack gap="xs" mt="md">
<Group justify="space-between">
<Text size="xs" c="dimmed">
{t("author", { name: item.authorName || t("communityMember") })}
{t("author", { name: item.authorName || "Community member" })}
</Text>
<Text size="sm" c="dimmed">
<IconThumbUp size={13} /> {item.upvotes} · <IconThumbDown size={13} /> {item.downvotes}
</Text>
</Group>
<Group gap="xs">
{item.outdated && <Badge color="yellow">{t("outdated")}</Badge>}
{item.reportCount > 0 && <Badge color="red">{t("reportCount", { count: item.reportCount })}</Badge>}
{item.outdated && <Badge color="yellow">Outdated</Badge>}
{item.reportCount > 0 && (
<Badge color="red">
{item.reportCount} report{item.reportCount === 1 ? "" : "s"}
</Badge>
)}
</Group>
<Text fw={700} lineClamp={1}>
{item.title}
@@ -345,7 +349,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
<Box>
<Title order={3}>{detail.data.title}</Title>
<Text size="sm" c="dimmed">
{t("author", { name: detail.data.authorName || t("communityMember") })}
{t("author", { name: detail.data.authorName || "Community member" })}
</Text>
</Box>
<Button
@@ -362,18 +366,20 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
{detail.data.description && <Text>{detail.data.description}</Text>}
{detail.data.outdated && (
<Alert color="yellow" icon={<IconAlertTriangle size={18} />}>
{t("outdatedWarning")}
The author marked this submission as outdated. Review it carefully before importing.
</Alert>
)}
{detail.data.reportCount > 0 && (
<Stack gap="xs">
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{t("reportWarning", { count: detail.data.reportCount })}
This submission has {detail.data.reportCount} open community report
{detail.data.reportCount === 1 ? "" : "s"}. It remains installable, but you should review its content
first.
</Alert>
{reportSummaries.data?.map((summary) => (
<Card key={summary.id} withBorder p="sm">
<Badge color="red" variant="light" mb="xs">
{t(`reportCategory.${summary.category}`)}
{summary.category}
</Badge>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{summary.explanation}
@@ -382,7 +388,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
))}
{reportSummaries.data?.length === 0 && (
<Text size="xs" c="dimmed">
{t("reportVisibility")}
Report details are visible only to this submission's author and Workshop admins.
</Text>
)}
</Stack>
@@ -394,7 +400,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
key={file}
src={client.fileUrl(detail.data.id, file, "960x640")}
radius="md"
alt={t("screenshotAlt", { title: detail.data.title, count: index + 1 })}
alt={`${detail.data.title} screenshot ${index + 1}`}
/>
))}
</SimpleGrid>
@@ -427,7 +433,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
leftSection={<IconThumbUp size={16} />}
loading={vote.isPending && vote.variables?.value === 1}
disabled={!user || vote.isPending}
aria-label={t("upvote", { count: detail.data.upvotes })}
onClick={() => vote.mutate({ submission: detail.data.id, value: 1 })}
>
{detail.data.upvotes}
@@ -437,7 +442,6 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
leftSection={<IconThumbDown size={16} />}
loading={vote.isPending && vote.variables?.value === -1}
disabled={!user || vote.isPending}
aria-label={t("downvote", { count: detail.data.downvotes })}
onClick={() => vote.mutate({ submission: detail.data.id, value: -1 })}
>
{detail.data.downvotes}
@@ -504,10 +508,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss, mo
label={t("reportReason")}
value={reportCategory}
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
data={(["outdated", "malicious", "spam", "copyright", "inappropriate", "other"] as const).map((value) => ({
value,
label: t(`reportCategory.${value}`),
}))}
data={["outdated", "malicious", "spam", "copyright", "inappropriate", "other"]}
allowDeselect={false}
/>
<Textarea
@@ -6,7 +6,6 @@ import { Alert, Button, Checkbox, FileInput, Group, Modal, Stack, Textarea, Text
import { clientApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useWorkshopCreateMutation } from "@homarr/workshop/backend";
import { workshopScreenshotsSchema } from "@homarr/workshop/schema";
import type { WorkshopUser } from "@homarr/workshop/schema";
import { useScopedI18n } from "@homarr/translation/client";
@@ -60,9 +59,6 @@ export function WorkshopPublishModal({
setError(null);
try {
if (!definition.data) throw new Error(t("publish.error"));
if (!workshopScreenshotsSchema.safeParse(screenshots).success) {
throw new Error(t("publish.invalidScreenshot"));
}
const inspectedDefinition = definition.data;
const result = await publishWorkshopDefinition({
inspectedDefinition,
+3 -14
View File
@@ -21,9 +21,7 @@
<div style="font-size: 12px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase">
Moderation alert
</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">
A Workshop submission was reported
</div>
<div style="margin-top: 8px; font-size: 22px; font-weight: bold">A Workshop submission was reported</div>
</td>
</tr>
<tr>
@@ -31,9 +29,7 @@
<p style="margin: 0 0 18px">
<strong>{{reporterName}}</strong> reported <a href="{{submissionUrl}}">{{submissionTitle}}</a>.
</p>
<p
style="margin: 0 0 8px; font-size: 12px; font-weight: bold; text-transform: uppercase; color: #991b1b"
>
<p style="margin: 0 0 8px; font-size: 12px; font-weight: bold; text-transform: uppercase; color: #991b1b">
{{category}}
</p>
<blockquote
@@ -43,14 +39,7 @@
</blockquote>
<a
href="{{adminUrl}}"
style="
display: inline-block;
padding: 12px 20px;
background: #991b1b;
color: #ffffff;
font-weight: bold;
text-decoration: none;
"
style="display: inline-block; padding: 12px 20px; background: #991b1b; color: #ffffff; font-weight: bold; text-decoration: none"
>
REVIEW REPORT
</a>
+1 -4
View File
@@ -36,10 +36,7 @@ describe("Custom JSX v2 workbench", () => {
await page.waitForLoadState("networkidle");
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
waitUntil: "commit",
timeout: 60_000,
});
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 15_000 });
await page.locator("button[type='submit']").click();
await signedIn;
@@ -8,20 +8,12 @@ import {
recordPreviewJournal,
resolvePreviewRequestParams,
} from "./preview-procedure-helpers";
import { executeCustomWidgetRequest, invalidateCustomWidgetResponseCache } from "./request-executor";
import { executeCustomWidgetRequest } from "./request-executor";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest";
import { acquireCustomWidgetRequestLimit } from "./request-limits";
import { getPreviewJournal, getPreviewSession, setPreviewSessionLiveActions } from "./preview-sessions";
export const previewQueryProcedures = {
previewRefresh: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.object({ sessionId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
invalidateCustomWidgetResponseCache([`custom-jsx:preview:${session.id}:`]);
}),
previewQuery: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
@@ -10,12 +10,11 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(async () => ({ ok: true, status: 200, statusText: "OK", data: { value: 42 } })),
invalidateResponseCache: vi.fn(),
}));
vi.mock("../../custom-widget/request-executor", () => ({
executeCustomWidgetRequest: mocks.executeRequest,
invalidateCustomWidgetResponseCache: mocks.invalidateResponseCache,
invalidateCustomWidgetResponseCache: vi.fn(),
}));
vi.mock("../../custom-widget/request-limits", () => ({
@@ -112,21 +111,7 @@ async function setup(
}
describe("Custom JSX v2 board router", () => {
beforeEach(() => {
mocks.executeRequest.mockClear();
mocks.invalidateResponseCache.mockClear();
});
test("refreshes the server response cache for a placed widget", async () => {
const { db, itemId } = await setup();
const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null });
await caller.refresh({ itemId });
expect(mocks.invalidateResponseCache).toHaveBeenCalledWith([
expect.stringMatching(new RegExp(`^custom-jsx:${itemId}:[a-f0-9]{16}:$`, "u")),
]);
});
beforeEach(() => mocks.executeRequest.mockClear());
test("loads placed widgets and public queries for anonymous visitors on public boards", async () => {
const { db, itemId } = await setup();
@@ -195,13 +195,6 @@ const executeRequest = async (
};
export const customApiRouter = createTRPCRouter({
refresh: publicProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
invalidateCustomWidgetResponseCache([
`custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:`,
]);
}),
getData: publicProcedure.input(itemInputSchema).query(async ({ ctx, input }) => {
const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId);
const loadRequests = Object.entries(resolved.definition.requests).filter(
@@ -53,10 +53,8 @@ export function SubFetch(props: SubFetchProps) {
(props.trigger !== "manual" || manualRun),
);
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
const scope = itemId ? "item" : "preview";
const scopeId = itemId ?? previewSessionId;
const query = useQuery({
queryKey: ["custom-widget", scope, scopeId, props.requestId, paramsKey],
queryKey: ["custom-widget", itemId ?? previewSessionId, props.requestId, paramsKey],
queryFn: ({ signal }) =>
port.query({ itemId, previewSessionId, requestId: props.requestId ?? "", params: params ?? {} }, signal),
enabled,
@@ -10,28 +10,8 @@ describe("Workshop navigation translations", () => {
const messages = JSON.parse(await readFile(resolve(languageDirectory, "en.json"), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
workshop?: Record<string, unknown>;
};
expect(messages.management?.navbar?.items?.workshop).toBe("Workshop");
expect(messages.navigationStructure?.manage?.workshop?.label).toBe("Workshop");
expect(messages.workshop).toMatchObject({
communityMember: "Community member",
outdated: "Outdated",
reportCount: expect.any(String),
reportWarning: expect.any(String),
reportVisibility: expect.any(String),
screenshotAlt: expect.any(String),
upvote: expect.any(String),
downvote: expect.any(String),
reportCategory: expect.objectContaining({
outdated: expect.any(String),
malicious: expect.any(String),
spam: expect.any(String),
copyright: expect.any(String),
inappropriate: expect.any(String),
other: expect.any(String),
}),
publish: expect.objectContaining({ invalidScreenshot: expect.any(String) }),
});
});
});
@@ -85,11 +85,6 @@ export class SynologyClient {
if (volumesFromV2.length > 0) {
return volumesFromV2;
}
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
if (legacyVolumesFromV2.length > 0) {
return legacyVolumesFromV2;
}
} catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId,
@@ -78,7 +78,6 @@ export const synologyStorageV2VolumeSchema = z
export const synologyStorageV2DataSchema = z.object({
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
vol_info: z.array(synologyVolumeInfoSchema).optional(),
});
export const synologyUtilizationCpuSchema = z.object({
@@ -426,60 +426,6 @@ describe("SynologyIntegration.getSystemInfoAsync", () => {
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
});
test("uses vol_info from storage_v2 without falling back to legacy storage", async () => {
mockSuccessfulFlow({
storageV2: {
success: true,
data: {
vol_info: [
{
name: "volume_1",
used_size: "4000000000",
total_size: "10000000000",
status: "normal",
},
{
name: "volume_2",
used_size: "5000000000",
total_size: "20000000000",
status: "attention",
},
],
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toEqual([
{
deviceName: "volume_1",
used: "4000000000",
available: "6000000000",
percentage: 40,
},
{
deviceName: "volume_2",
used: "5000000000",
available: "15000000000",
percentage: 25,
},
]);
const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => {
const urlString = toUrlString(url);
return (
getApiFromUrl(urlString) === "SYNO.Core.System" &&
getMethodFromUrl(urlString) === "info" &&
getTypeFromUrl(urlString) === "storage"
);
});
expect(legacyStorageRequests).toHaveLength(0);
});
test("continues when optional tier-2 calls fail", async () => {
mockSuccessfulFlow({ skipOptional: true });
const integration = createIntegration();
-18
View File
@@ -1151,15 +1151,6 @@
"emptyCss": "No CSS themes match this search.",
"noDescription": "No description",
"author": "by {name}",
"communityMember": "Community member",
"outdated": "Outdated",
"outdatedWarning": "The author marked this submission as outdated. Review it carefully before importing.",
"reportCount": "{count, plural, one {# report} other {# reports}}",
"reportWarning": "{count, plural, one {This submission has # open community report.} other {This submission has # open community reports.}} It remains installable, but you should review its content first.",
"reportVisibility": "Report details are visible only to this submission's author and Workshop admins.",
"screenshotAlt": "{title} screenshot {count, number}",
"upvote": "Upvote, {count, plural, one {# vote} other {# votes}}",
"downvote": "Downvote, {count, plural, one {# vote} other {# votes}}",
"inspectCss": "Inspect CSS",
"sourceCode": "Workshop entry",
"loadError": "This submission could not be loaded.",
@@ -1181,14 +1172,6 @@
"reportSend": "Send report",
"reportSent": "Report sent",
"reportSentDescription": "Workshop moderators can now review this submission.",
"reportCategory": {
"outdated": "Outdated or no longer working",
"malicious": "Malicious",
"spam": "Spam",
"copyright": "Copyright",
"inappropriate": "Inappropriate",
"other": "Other"
},
"voteError": "The vote could not be saved.",
"publish": {
"title": "Publish to Workshop",
@@ -1198,7 +1181,6 @@
"descriptionField": "Description",
"screenshots": "Screenshots",
"screenshotsDescription": "Up to five PNG, JPEG, or WebP images",
"invalidScreenshot": "Use PNG, JPEG, or WebP images no larger than 5 MB each.",
"privateSourceWarning": "This widget contains private source URLs for: {sources}. Replace personal hosts or IP addresses with safe service placeholders before publishing.",
"privateSourceConfirmation": "I reviewed these source URLs and they do not expose private infrastructure details",
"action": "Publish now",
+79 -21
View File
@@ -1,6 +1,8 @@
"use client";
import type { CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
@@ -45,7 +47,7 @@ export interface TooltipPayloadItem {
payload?: Record<string, unknown>;
}
interface BeszelTooltipProps {
interface PortalTooltipProps {
active?: boolean;
label?: string;
payload?: TooltipPayloadItem[];
@@ -53,8 +55,53 @@ interface BeszelTooltipProps {
showTotal?: boolean;
}
const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }: BeszelTooltipProps) => {
const MARGIN = 14;
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
const mouseRef = useRef({ x: 0, y: 0 });
const [pos, setPos] = useState({ x: 0, y: 0 });
const rafRef = useRef(0);
const tooltipRef = useRef<HTMLDivElement>(null);
const handleMouse = useCallback((e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
const el = tooltipRef.current;
const mx = mouseRef.current.x;
const my = mouseRef.current.y;
if (!el) {
setPos({ x: mx + MARGIN, y: my });
return;
}
const { width, height } = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let x = mx + MARGIN;
let y = my - height / 2;
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
if (y < MARGIN) y = MARGIN;
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
setPos({ x, y });
});
}, []);
const isActive = active && (payload?.length ?? 0) > 0;
useEffect(() => {
if (!isActive) return;
document.addEventListener("mousemove", handleMouse);
return () => {
document.removeEventListener("mousemove", handleMouse);
cancelAnimationFrame(rafRef.current);
};
}, [isActive, handleMouse]);
if (!isActive) return null;
const deduped = new Map<string, TooltipPayloadItem>();
@@ -69,30 +116,41 @@ const BeszelTooltipContent = ({ active, label, payload, formatter, showTotal }:
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
const total = sorted.reduce((sum, p) => sum + p.value, 0);
return (
<div style={styles.wrapper}>
<div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
const portalStyle: CSSProperties = {
position: "fixed",
left: pos.x,
top: pos.y,
zIndex: 10000,
pointerEvents: "none",
};
return createPortal(
<div ref={tooltipRef} style={portalStyle}>
<div style={styles.wrapper}>
<div style={styles.header}>{tooltipLabel}</div>
{sorted.map((item) => (
<div key={item.dataKey} style={styles.row}>
<div style={{ ...styles.indicator, background: item.color }} />
<span style={styles.name}>{item.name}</span>
<span style={styles.value}>{formatter(item.value)}</span>
</div>
))}
{showTotal && sorted.length > 1 && (
<div style={{ ...styles.row, ...styles.separator }}>
<span style={styles.name}>Total</span>
<span style={styles.value}>{formatter(total)}</span>
</div>
)}
</div>
</div>,
document.body,
);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const makeTooltipProps = (formatter: (v: number) => string, showTotal = false) => ({
content: (props: any) => (
<BeszelTooltipContent
<PortalTooltipContent
active={props.active}
label={props.label != null ? String(props.label) : undefined}
payload={props.payload}
@@ -100,6 +158,6 @@ export const makeTooltipProps = (formatter: (v: number) => string, showTotal = f
showTotal={showTotal}
/>
),
wrapperStyle: { zIndex: 10000, pointerEvents: "none" as const },
wrapperStyle: { visibility: "hidden" as const, position: "absolute" as const, width: 0, height: 0 },
isAnimationActive: false,
});
@@ -107,42 +107,26 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
confirmed: input.confirmed,
}),
invalidate: async ({ itemId, previewSessionId, targets }) => {
const invalidateAll = targets.includes("*");
if (previewSessionId) {
if (invalidateAll) {
await fetchApi.customWidget.previewRefresh.mutate({ sessionId: previewSessionId });
}
await queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key[0] === "custom-widget" &&
key[1] === "preview" &&
key[2] === previewSessionId &&
(invalidateAll || targets.includes(String(key[3])))
);
return key[0] === "custom-widget" && key[1] === previewSessionId && targets.includes(String(key[2]));
},
});
return;
}
if (!itemId) return;
if (targets.length === 0) return;
if (invalidateAll) await fetchApi.widget.customApi.refresh.mutate({ itemId });
const tasks: Promise<unknown>[] = [
queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
key[0] === "custom-widget" &&
key[1] === "item" &&
key[2] === itemId &&
(invalidateAll || targets.includes(String(key[3])))
);
return key[0] === "custom-widget" && key[1] === itemId && targets.includes(String(key[2]));
},
}),
];
if (
invalidateAll ||
(props.requestCapabilities ?? []).some((request) => request.trigger === "load" && targets.includes(request.id))
) {
tasks.push(utils.widget.customApi.getData.invalidate({ itemId }));