fix(custom-widgets): address v2 merge review
This commit is contained in:
1 parent
6aceb2fd1f
commit
48ce83806d
122 files changed
+8534
-1447
No files matched your search
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: Component reference
|
||||
sidebar_position: 3
|
||||
tags: [Custom Widgets, JSX, Mantine, Components]
|
||||
---
|
||||
|
||||
import { CustomJsxComponentReference } from "@site/src/components/custom-jsx-component-reference";
|
||||
|
||||
# Custom JSX component reference
|
||||
|
||||
Search the components and properties exposed by Homarr's safe Custom JSX runtime. The live schema and diagnostics remain authoritative: properties marked as blocked cannot be passed from widget code, and denied components are shown only for explanation.
|
||||
|
||||
<CustomJsxComponentReference />
|
||||
@@ -53,13 +53,14 @@ The browser renders JSX. Homarr's server executes named requests, applies board
|
||||
|
||||
- [Create a Custom Widget](./authoring) visually or with one copied AI prompt.
|
||||
- [Use the JSX runtime](./custom-jsx) for data, Mantine components, temporary inputs, and diagnostics.
|
||||
- [Browse the supported component reference](./component-reference).
|
||||
- [Configure requests and security](./requests-and-security).
|
||||
- [Use MCP and the skills.sh skill](./agent-authoring).
|
||||
- [Correct problems](./troubleshooting).
|
||||
|
||||
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and only disable the widget preview.
|
||||
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected.
|
||||
|
||||
Exports, imports, Workshop, MCP, and storage all validate the same schema. Old formats are rejected. A missing definition only renders **Custom widget unavailable** inside its tile.
|
||||
Exports, imports, Workshop, MCP, and storage all validate the same schema. Widgets saved in the legacy format remain visible with a **Migration required** badge after upgrading. Use **Copy migration prompt**, give the redacted prompt to an LLM, then import its v2 JSON from that widget's action menu. Homarr keeps the legacy record until the replacement validates and is confirmed; credentials are never included in the prompt.
|
||||
|
||||
When adding widgets while editing a board, the Custom Widgets section always includes **Install from Workshop**. The Workshop and API source review open above the widget picker without closing it. After import, the new definition appears in the picker. Its tile waits for the board to be saved before server requests run and loads automatically after a successful save; no page reload is required.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ An incomplete API source URL or icon URL causes a form validation error. The wor
|
||||
|
||||
The workbench marks the invalid field while you type. An invalid URL does not replace the form with the error boundary.
|
||||
|
||||
An invalid definition disables only the widget preview. The Data, Options, Actions, and Diagnostics tabs remain available.
|
||||
An invalid definition disables widget preview and Save/Create. The Data, Options, Actions, and Diagnostics tabs remain available so you can correct it.
|
||||
|
||||
The Diagnostics tab shows the current schema errors.
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Because Workshop is still unreleased, reset an older development volume if it pr
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `WORKSHOP_PUBLIC_ORIGIN`, and PocketBase SMTP settings for authentication, comment email, report alerts, and submission-removal notices.
|
||||
|
||||
Homarr instances use `NEXT_PUBLIC_WORKSHOP_API_URL` to reach the central API and `NEXT_PUBLIC_WORKSHOP_URL` for links to the public page. Docusaurus uses `WORKSHOP_URL` only when its development server runs separately; the combined production image always uses its own origin.
|
||||
Homarr instances use the server-side `WORKSHOP_API_URL` variable to reach the central API. The Docusaurus site uses `WORKSHOP_URL` when its development server runs separately; the combined production image always uses its own origin. Restart the corresponding process after changing either value because both are read at startup.
|
||||
|
||||
## Client architecture
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ import { CodeBlock, DeleteConfirmButton, DetailSkeleton, ScreenshotGallery } fro
|
||||
import { formatRelativeTime } from "./format";
|
||||
import { ScreenshotEditor } from "./ScreenshotEditor";
|
||||
import { WorkshopErrorBoundary } from "./WorkshopErrorBoundary";
|
||||
import { downloadSubmissionJson, voteDelta } from "./workshop-utils";
|
||||
import { downloadSubmissionJson, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
const typeLabels: Record<SubmissionType, string> = { customCss: "CSS", customWidget: "Widget" };
|
||||
const typeDotColors: Record<SubmissionType, string> = { customCss: "bg-blue-500", customWidget: "bg-yellow-500" };
|
||||
@@ -305,28 +305,16 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
if (!submission || voting.current) return;
|
||||
voting.current = true;
|
||||
|
||||
const userId = await requireUserId("vote");
|
||||
if (!userId) {
|
||||
if (!(await requireUserId("vote"))) {
|
||||
voting.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = userVote;
|
||||
const isToggleOff = prev?.value === value;
|
||||
const [upD, downD] = voteDelta(prev?.value, value);
|
||||
|
||||
setUserVote(
|
||||
isToggleOff
|
||||
? undefined
|
||||
: ({ ...(prev ?? { id: "", submission: submission.id, user: userId }), value } as WorkshopVote),
|
||||
);
|
||||
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes + upD, downvotes: s.downvotes + downD } : s));
|
||||
|
||||
try {
|
||||
setUserVote((await backend.vote(submission.id, value)) ?? undefined);
|
||||
const reconciled = await voteAndReconcile(backend, submission.id, value);
|
||||
setSubmission(reconciled.submission);
|
||||
setUserVote(reconciled.votes.find((vote) => vote.submission === submission.id));
|
||||
} catch (caught) {
|
||||
setUserVote(prev);
|
||||
setSubmission((s) => (s ? { ...s, upvotes: s.upvotes - upD, downvotes: s.downvotes - downD } : s));
|
||||
setError(errorMessage(caught, "Failed to register your vote"));
|
||||
} finally {
|
||||
voting.current = false;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React, { 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 { ScreenshotGallery } from "./DetailSections";
|
||||
|
||||
vi.mock("@docusaurus/theme-common", () => ({ useColorMode: () => ({ colorMode: "light" }) }));
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
async function render(urls: string[]) {
|
||||
await act(async () => root.render(<ScreenshotGallery urls={urls} title="Gallery" />));
|
||||
}
|
||||
|
||||
describe("ScreenshotGallery", () => {
|
||||
it("resets an out-of-range selection when screenshots shrink", async () => {
|
||||
await render(["/one.png", "/two.png", "/three.png"]);
|
||||
const thirdThumbnail = host.querySelector<HTMLButtonElement>('button[aria-label="Show screenshot 3"]');
|
||||
if (!thirdThumbnail) throw new Error("Third screenshot thumbnail was not rendered");
|
||||
await act(async () => thirdThumbnail.click());
|
||||
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 3"]')?.src).toContain("/three.png");
|
||||
|
||||
await render(["/one.png"]);
|
||||
expect(host.querySelector<HTMLImageElement>('img[alt="Gallery screenshot 1"]')?.src).toContain("/one.png");
|
||||
expect(host.querySelector('img[alt="Gallery screenshot 3"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useColorMode } from "@docusaurus/theme-common";
|
||||
import { IconChevronLeft, IconChevronRight, IconTrash } from "@tabler/icons-react";
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@site/src/lib/utils";
|
||||
import { clampScreenshotIndex } from "./workshop-utils";
|
||||
|
||||
const prismThemes = { light: themes.github, dark: themes.dracula } as const;
|
||||
const colorModeKeys = { dark: "dark", light: "light" } as const;
|
||||
@@ -44,11 +45,19 @@ export const DetailSkeleton = () => (
|
||||
|
||||
export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: string }) => {
|
||||
const [idx, setIdx] = useState(0);
|
||||
const selectedIndex = clampScreenshotIndex(idx, urls.length);
|
||||
useEffect(() => setIdx((current) => clampScreenshotIndex(current, urls.length)), [urls.length]);
|
||||
|
||||
if (urls.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="group/gallery w-full">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<img className="h-full w-full object-contain" src={urls[idx]} alt={`${title} screenshot ${idx + 1}`} />
|
||||
<img
|
||||
className="h-full w-full object-contain"
|
||||
src={urls[selectedIndex]}
|
||||
alt={`${title} screenshot ${selectedIndex + 1}`}
|
||||
/>
|
||||
{urls.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -75,7 +84,7 @@ export const ScreenshotGallery = ({ urls, title }: { urls: string[]; title: stri
|
||||
<legend className="sr-only">Screenshot previews</legend>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{urls.map((url, i) => {
|
||||
const selected = i === idx;
|
||||
const selected = i === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -31,6 +31,7 @@ import { cn, errorMessage } from "@/lib/utils";
|
||||
|
||||
import type { SubmitInput } from "./useWorkshop";
|
||||
import { ScreenshotEditor } from "./ScreenshotEditor";
|
||||
import { getJsonImportSubmissionType } from "./submission-import";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -104,7 +105,7 @@ export const SubmitForm = ({ onClose, onSubmit }: Props) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", (e) => {
|
||||
const text = e.target?.result as string;
|
||||
if (!type) setType("customWidget");
|
||||
setType(getJsonImportSubmissionType(type));
|
||||
setContentAndAutofill(text, title, description);
|
||||
if (step === 0) goTo(1);
|
||||
});
|
||||
@@ -400,6 +401,7 @@ const StepType = ({
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
aria-label="Choose a widget JSON file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getJsonImportSubmissionType } from "./submission-import";
|
||||
|
||||
describe("Workshop JSON import classification", () => {
|
||||
it("switches an existing Custom CSS draft to Custom Widget", () => {
|
||||
expect(getJsonImportSubmissionType("customCss")).toBe("customWidget");
|
||||
});
|
||||
|
||||
it("classifies JSON before a submission type is selected", () => {
|
||||
expect(getJsonImportSubmissionType(null)).toBe("customWidget");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
|
||||
export const getJsonImportSubmissionType = (_currentType: SubmissionType | null): SubmissionType => "customWidget";
|
||||
@@ -7,7 +7,7 @@ import type { SubmissionType } from "@site/src/lib/workshop-schema";
|
||||
import { validateSubmissionContent } from "@site/src/lib/workshop-schema";
|
||||
import { errorMessage, oauthErrorMessage } from "@site/src/lib/utils";
|
||||
|
||||
import { voteDelta } from "./workshop-utils";
|
||||
import { voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
export type SortKey = "top" | "new" | "recent" | "discussed";
|
||||
export type TypeFilter = "all" | "yours" | SubmissionType;
|
||||
@@ -131,58 +131,32 @@ export const useWorkshop = (workshopUrl: string) => {
|
||||
if (votingIds.current.has(submissionId)) return;
|
||||
votingIds.current.add(submissionId);
|
||||
|
||||
const userId = await requireUserId("vote");
|
||||
if (!userId) {
|
||||
if (!(await requireUserId("vote"))) {
|
||||
votingIds.current.delete(submissionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = votes[submissionId];
|
||||
const isToggleOff = prev?.value === value;
|
||||
const [upD, downD] = voteDelta(prev?.value, value);
|
||||
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (isToggleOff) delete next[submissionId];
|
||||
else
|
||||
next[submissionId] = {
|
||||
...(prev ?? { id: "", submission: submissionId, user: userId, created: "", updated: "" }),
|
||||
value,
|
||||
} as WorkshopVote;
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes + upD, downvotes: sub.downvotes + downD } : sub,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const saved = await backend.vote(submissionId, value);
|
||||
setVotes((current) => {
|
||||
const next = { ...current };
|
||||
if (saved) next[submissionId] = saved;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
} catch (caught) {
|
||||
setVotes((v) => {
|
||||
const next = { ...v };
|
||||
if (prev) next[submissionId] = prev;
|
||||
else delete next[submissionId];
|
||||
return next;
|
||||
});
|
||||
setSubmissions((s) =>
|
||||
s.map((sub) =>
|
||||
sub.id === submissionId ? { ...sub, upvotes: sub.upvotes - upD, downvotes: sub.downvotes - downD } : sub,
|
||||
const reconciled = await voteAndReconcile(backend, submissionId, value);
|
||||
setVotes(Object.fromEntries(reconciled.votes.map((row) => [row.submission, row])));
|
||||
setSubmissions((current) =>
|
||||
current.map((submission) =>
|
||||
submission.id === submissionId
|
||||
? {
|
||||
...submission,
|
||||
upvotes: reconciled.submission.upvotes,
|
||||
downvotes: reconciled.submission.downvotes,
|
||||
}
|
||||
: submission,
|
||||
),
|
||||
);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught, "Failed to register your vote"));
|
||||
} finally {
|
||||
votingIds.current.delete(submissionId);
|
||||
}
|
||||
},
|
||||
[backend, votes, requireUserId],
|
||||
[backend, requireUserId],
|
||||
);
|
||||
|
||||
const report = useCallback(
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
|
||||
import { clampScreenshotIndex, voteAndReconcile } from "./workshop-utils";
|
||||
|
||||
describe("Workshop state reconciliation", () => {
|
||||
it("reloads both the submission counts and authenticated vote after voting", async () => {
|
||||
const submission = { id: "submission-1", upvotes: 8, downvotes: 2 } as WorkshopSubmission;
|
||||
const vote = { id: "vote-1", submission: "submission-1", value: 1 } as WorkshopVote;
|
||||
const backend = {
|
||||
vote: vi.fn().mockResolvedValue(vote),
|
||||
get: vi.fn().mockResolvedValue(submission),
|
||||
listVotesForCurrentUser: vi.fn().mockResolvedValue([vote]),
|
||||
};
|
||||
|
||||
await expect(voteAndReconcile(backend, submission.id, 1)).resolves.toEqual({ submission, votes: [vote] });
|
||||
expect(backend.vote).toHaveBeenCalledWith(submission.id, 1);
|
||||
expect(backend.get).toHaveBeenCalledWith(submission.id);
|
||||
expect(backend.listVotesForCurrentUser).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("clamps a selected screenshot when the gallery shrinks", () => {
|
||||
expect(clampScreenshotIndex(3, 2)).toBe(1);
|
||||
expect(clampScreenshotIndex(2, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase";
|
||||
|
||||
export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
const url = URL.createObjectURL(new Blob([submission.content], { type: "application/json" }));
|
||||
@@ -9,8 +9,17 @@ export const downloadSubmissionJson = (submission: WorkshopSubmission) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const voteDelta = (prev: 1 | -1 | undefined, next: 1 | -1): [up: number, down: number] => {
|
||||
if (!prev) return next === 1 ? [1, 0] : [0, 1];
|
||||
if (prev === next) return next === 1 ? [-1, 0] : [0, -1];
|
||||
return next === 1 ? [1, -1] : [-1, 1];
|
||||
interface WorkshopVoteBackend {
|
||||
vote(submissionId: string, value: 1 | -1): Promise<WorkshopVote | null>;
|
||||
get(submissionId: string): Promise<WorkshopSubmission>;
|
||||
listVotesForCurrentUser(): Promise<WorkshopVote[]>;
|
||||
}
|
||||
|
||||
export const voteAndReconcile = async (backend: WorkshopVoteBackend, submissionId: string, value: 1 | -1) => {
|
||||
await backend.vote(submissionId, value);
|
||||
const [submission, votes] = await Promise.all([backend.get(submissionId), backend.listVotesForCurrentUser()]);
|
||||
return { submission, votes };
|
||||
};
|
||||
|
||||
export const clampScreenshotIndex = (index: number, screenshotCount: number) =>
|
||||
Math.min(Math.max(0, index), Math.max(0, screenshotCount - 1));
|
||||
+3010
-543
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user