feat(custom-widgets): complete Custom JSX v2 (#6390)
This commit is contained in:
1 parent
3a1245cbc5
commit
acdfcbb18e
416 files changed
+37853
-18066
No files matched your search
@@ -1,52 +1,21 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { WorkshopBackend, type TypedWorkshopPocketBase } from "@homarr/workshop/backend";
|
||||
import type { WorkshopSubmissionSummary } from "@homarr/workshop/schema";
|
||||
|
||||
let client: PocketBase | undefined;
|
||||
let clientUrl: string | undefined;
|
||||
export type { WorkshopComment, WorkshopVote } from "@homarr/workshop/schema";
|
||||
|
||||
export const getPocketBase = (url: string): PocketBase => {
|
||||
if (!client || clientUrl !== url) {
|
||||
client = new PocketBase(url);
|
||||
clientUrl = url;
|
||||
}
|
||||
return client;
|
||||
const backends = new Map<string, WorkshopBackend>();
|
||||
|
||||
export const getWorkshopBackend = (url: string): WorkshopBackend => {
|
||||
const normalizedUrl = url.replace(/\/$/u, "");
|
||||
const existing = backends.get(normalizedUrl);
|
||||
if (existing) return existing;
|
||||
const backend = new WorkshopBackend(normalizedUrl);
|
||||
backends.set(normalizedUrl, backend);
|
||||
return backend;
|
||||
};
|
||||
|
||||
export const getSubmissionFileUrl = (baseUrl: string, submissionId: string, filename: string) =>
|
||||
`${baseUrl}/api/files/submissions/${submissionId}/${encodeURIComponent(filename)}`;
|
||||
export const getPocketBase = (url: string): TypedWorkshopPocketBase => getWorkshopBackend(url).pocketBase;
|
||||
|
||||
export interface WorkshopSubmission {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
type: "css" | "widget";
|
||||
title: string;
|
||||
description: string;
|
||||
schemaVersion: string;
|
||||
export type WorkshopSubmission = WorkshopSubmissionSummary & {
|
||||
content: string;
|
||||
screenshots: string[];
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
commentCount: number;
|
||||
version: number;
|
||||
changelog: string;
|
||||
author: string;
|
||||
authorName: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface WorkshopVote {
|
||||
id: string;
|
||||
submission: string;
|
||||
user: string;
|
||||
value: 1 | -1;
|
||||
}
|
||||
|
||||
export interface WorkshopComment {
|
||||
id: string;
|
||||
submission: string;
|
||||
author: string;
|
||||
content: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
expand?: { author?: { id: string; name?: string; username?: string } };
|
||||
}
|
||||
};
|
||||
@@ -9,3 +9,13 @@ export function cn(...inputs: ClassValue[]) {
|
||||
export function errorMessage(caught: unknown, fallback: string) {
|
||||
return caught instanceof Error ? caught.message : fallback;
|
||||
}
|
||||
|
||||
export function oauthErrorMessage(caught: unknown) {
|
||||
const message = errorMessage(caught, "GitHub sign-in failed");
|
||||
if (/popup.*block|blocked.*popup/iu.test(message))
|
||||
return "GitHub sign-in popup was blocked. Allow popups for this site and try again.";
|
||||
if (/cancel|closed.*popup|popup.*closed/iu.test(message)) return "GitHub sign-in was cancelled before it completed.";
|
||||
if (/provider|oauth|redirect|callback|client.?id/iu.test(message))
|
||||
return "GitHub sign-in is not configured correctly on the Workshop server.";
|
||||
return message;
|
||||
}
|
||||
@@ -1,49 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { validateSubmissionContent } from "./workshop-schema";
|
||||
|
||||
const validWidget = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Example",
|
||||
url: "https://example.com/api",
|
||||
authType: "none",
|
||||
method: "GET",
|
||||
displayType: "singleValue",
|
||||
displayConfig: { type: "singleValue", jsonPath: "$.count", label: "Count", unit: "" },
|
||||
};
|
||||
const validWidget = CUSTOM_WIDGET_STARTER;
|
||||
|
||||
describe("validateSubmissionContent", () => {
|
||||
it("accepts a valid homarr-custom-widget-v2 JSON", () => {
|
||||
expect(validateSubmissionContent("widget", JSON.stringify(validWidget)).success).toBe(true);
|
||||
expect(validateSubmissionContent("customWidget", JSON.stringify(validWidget)).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an old/unknown widget schema version", () => {
|
||||
const result = validateSubmissionContent(
|
||||
"widget",
|
||||
"customWidget",
|
||||
JSON.stringify({ ...validWidget, $schema: "homarr-custom-widget-v1" }),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects widgets without a schema version", () => {
|
||||
const widgetWithoutSchema = {
|
||||
name: validWidget.name,
|
||||
url: validWidget.url,
|
||||
authType: validWidget.authType,
|
||||
method: validWidget.method,
|
||||
displayType: validWidget.displayType,
|
||||
displayConfig: validWidget.displayConfig,
|
||||
};
|
||||
const { $schema: _schema, ...widgetWithoutSchema } = validWidget;
|
||||
|
||||
expect(validateSubmissionContent("widget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
|
||||
expect(validateSubmissionContent("customWidget", JSON.stringify(widgetWithoutSchema)).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed widget JSON", () => {
|
||||
expect(validateSubmissionContent("widget", "{ not json").success).toBe(false);
|
||||
expect(validateSubmissionContent("customWidget", "{ not json").success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts non-empty CSS and rejects empty CSS", () => {
|
||||
expect(validateSubmissionContent("css", ".card { color: red; }").success).toBe(true);
|
||||
expect(validateSubmissionContent("css", " ").success).toBe(false);
|
||||
expect(validateSubmissionContent("customCss", ".card { color: red; }").success).toBe(true);
|
||||
expect(validateSubmissionContent("customCss", " ").success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { CUSTOM_WIDGET_SCHEMA, type HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
MAX_WORKSHOP_CSS_LENGTH,
|
||||
type WorkshopSubmissionType,
|
||||
validateWorkshopContent,
|
||||
WORKSHOP_CSS_SCHEMA,
|
||||
} from "@homarr/workshop/schema";
|
||||
|
||||
export type SubmissionType = "css" | "widget";
|
||||
export type SubmissionType = WorkshopSubmissionType;
|
||||
export const WIDGET_SCHEMA_VERSION = CUSTOM_WIDGET_SCHEMA;
|
||||
export const CSS_SCHEMA_VERSION = WORKSHOP_CSS_SCHEMA;
|
||||
export const MAX_CSS_LENGTH = MAX_WORKSHOP_CSS_LENGTH;
|
||||
|
||||
export const WIDGET_SCHEMA_VERSION = "homarr-custom-widget-v2";
|
||||
export const CSS_SCHEMA_VERSION = "homarr-custom-css-v1";
|
||||
|
||||
export const MAX_CSS_LENGTH = 16384;
|
||||
|
||||
export type WorkshopValidationResult = { success: true; data: unknown } | { success: false; error: string };
|
||||
|
||||
const authTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
|
||||
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
|
||||
const displayTypes = [
|
||||
"singleValue",
|
||||
"keyValue",
|
||||
"table",
|
||||
"statGrid",
|
||||
"progressBars",
|
||||
"statusIndicator",
|
||||
"countGrid",
|
||||
"raw",
|
||||
"actionButton",
|
||||
"customJsx",
|
||||
] as const;
|
||||
|
||||
const widgetSchema = z
|
||||
.object({
|
||||
$schema: z.literal(WIDGET_SCHEMA_VERSION),
|
||||
name: z.string().min(1).max(128),
|
||||
url: z.string().min(1),
|
||||
authType: z.enum(authTypes),
|
||||
method: z.enum(methods),
|
||||
displayType: z.enum(displayTypes),
|
||||
displayConfig: z.object({ type: z.enum(displayTypes) }),
|
||||
})
|
||||
.refine((widget) => widget.displayConfig.type === widget.displayType, {
|
||||
message: "displayConfig.type must match displayType",
|
||||
});
|
||||
|
||||
const validateWidgetContent = (raw: string): WorkshopValidationResult => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { success: false, error: "Content is not valid JSON" };
|
||||
}
|
||||
|
||||
const result = widgetSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error.issues[0]?.message ?? "Invalid custom widget JSON" };
|
||||
}
|
||||
|
||||
return { success: true, data: result.data };
|
||||
};
|
||||
|
||||
const validateCssContent = (raw: string): WorkshopValidationResult => {
|
||||
if (raw.trim().length === 0) {
|
||||
return { success: false, error: "CSS cannot be empty" };
|
||||
}
|
||||
if (raw.length > MAX_CSS_LENGTH) {
|
||||
return { success: false, error: `CSS must be at most ${MAX_CSS_LENGTH} characters` };
|
||||
}
|
||||
return { success: true, data: raw };
|
||||
};
|
||||
|
||||
export const submissionValidators = {
|
||||
widget: validateWidgetContent,
|
||||
css: validateCssContent,
|
||||
} satisfies Record<SubmissionType, (raw: string) => WorkshopValidationResult>;
|
||||
export type WorkshopValidationResult =
|
||||
| { success: true; data: HomarrCustomWidgetV2 | string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const schemaVersionByType = {
|
||||
widget: WIDGET_SCHEMA_VERSION,
|
||||
css: CSS_SCHEMA_VERSION,
|
||||
customWidget: WIDGET_SCHEMA_VERSION,
|
||||
customCss: CSS_SCHEMA_VERSION,
|
||||
} satisfies Record<SubmissionType, string>;
|
||||
|
||||
export const validateSubmissionContent = (type: SubmissionType, raw: string): WorkshopValidationResult =>
|
||||
submissionValidators[type](raw);
|
||||
validateWorkshopContent(type, raw);
|
||||
Reference in new issue
Block a user