Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f071cba70d | ||
|
|
827ba7cda4 | ||
|
|
44c22ccad3 | ||
|
|
eae17877a1 | ||
|
|
6435955a51 | ||
|
|
7a45b0d119 | ||
|
|
8b49982c3a | ||
|
|
ad2fa1605e | ||
|
|
a6061348c3 | ||
|
|
e524515ca5 | ||
|
|
cf3c4b4152 | ||
|
|
378bc0cd28 | ||
|
|
be93fb330a | ||
|
|
893fcd6ed1 | ||
|
|
0422675bc1 | ||
|
|
d610206896 | ||
|
|
64403079d8 | ||
|
|
015e0cbf63 | ||
|
|
52266fc7f6 | ||
|
|
d53021a0f3 | ||
|
|
c7e6ed8943 | ||
|
|
5efe053c0f | ||
|
|
1b489cc2e0 | ||
|
|
7cf27c3f39 | ||
|
|
8a3f8d5c12 | ||
|
|
1028752cbc | ||
|
|
b9c89c61a2 | ||
|
|
b5dbeae864 | ||
|
|
d513c7fe46 | ||
|
|
8dad83f720 | ||
|
|
bbc23a6c65 | ||
|
|
5ee14136ac | ||
|
|
9d3d40d4bd | ||
|
|
48d8517ed1 | ||
|
|
7619492516 | ||
|
|
ca6a019c1e | ||
|
|
cae976b6d8 | ||
|
|
215f9a7d21 | ||
|
|
dec46f3ee4 | ||
|
|
aaf86a9d13 | ||
|
|
2efc225617 | ||
|
|
dd63d5600b | ||
|
|
25be53244b | ||
|
|
8ab3f136b8 | ||
|
|
557ebe0a2f | ||
|
|
e8a2c87c2d | ||
|
|
4f6e335f72 | ||
|
|
df967751f6 | ||
|
|
6aceb2fd1f | ||
|
|
60cbd7bb7a | ||
|
|
c0f1d6624c | ||
|
|
67d146220a | ||
|
|
41db737905 | ||
|
|
3b128585a3 | ||
|
|
cdaf2a838c | ||
|
|
926a6856fb | ||
|
|
c6160da222 | ||
|
|
e729dfd891 | ||
|
|
eb71f936c6 | ||
|
|
c10ebbc863 | ||
|
|
106c3bfa90 | ||
|
|
27a6467896 | ||
|
|
8331a58471 | ||
|
|
ec74c36a6a | ||
|
|
487d98275c | ||
|
|
187a479bd5 | ||
|
|
0bfe6ddabe | ||
|
|
51d8a3cc53 | ||
|
|
06db42129c | ||
|
|
a11583feb1 | ||
|
|
446931bc0a | ||
|
|
34bcc62415 | ||
|
|
a2e3c5d3bc | ||
|
|
ed9646b0c6 | ||
|
|
e0de370340 | ||
|
|
7e5453d47c | ||
|
|
3b2fc2527e | ||
|
|
0a0b3a12c9 | ||
|
|
ba263dbe47 | ||
|
|
8a7c4875bf | ||
|
|
377f2f6a20 | ||
|
|
c69e33ac8c | ||
|
|
121a83040d |
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}
|
||||
|
||||
+1
-8
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
+1
-3
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in new issue
Block a user