fix(custom-widgets): finish v2 hardening
This commit is contained in:
97 files changed
+2833
-12393
No files matched your search
@@ -323,14 +323,6 @@
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.codeBlock {
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.a11yList {
|
||||
font-size: 0.86rem;
|
||||
margin: 0 0 0.7rem;
|
||||
@@ -389,7 +381,7 @@
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.rawComponent .codeBlock {
|
||||
.rawComponent > div {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,48 +2,19 @@ import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import useBaseUrl from "@docusaurus/useBaseUrl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { buildCustomJsxComponentUsageExample } from "@homarr/custom-widgets/catalog-example";
|
||||
import type {
|
||||
CustomJsxAuthoringCatalog,
|
||||
CustomJsxComponentApi,
|
||||
CustomJsxPropDescriptor,
|
||||
} from "@homarr/custom-widgets/catalog";
|
||||
|
||||
import { CustomWidgetCodeExample } from "./custom-widget-code";
|
||||
import styles from "./custom-jsx-component-reference.module.css";
|
||||
|
||||
type ComponentSafety = "allowed" | "wrapped" | "denied";
|
||||
type ComponentPackage = "@mantine/core" | "@mantine/dates" | "@mantine/charts" | "@homarr/widgets";
|
||||
type ComponentBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
|
||||
|
||||
interface CatalogProp {
|
||||
name: string;
|
||||
typeRef: number;
|
||||
required: boolean;
|
||||
source: "global" | "component";
|
||||
literalValues?: Array<string | number | boolean | null>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CatalogComponent {
|
||||
name: string;
|
||||
package: ComponentPackage;
|
||||
category: string;
|
||||
safety: ComponentSafety;
|
||||
description?: string;
|
||||
documentationUrl: string;
|
||||
props: CatalogProp[];
|
||||
blockedProps: Array<{ name: string; reason: string }>;
|
||||
subcomponents: string[];
|
||||
bind?: {
|
||||
type: ComponentBindingType;
|
||||
initialProp: "defaultValue" | "defaultChecked";
|
||||
};
|
||||
accessibilityRequirements: string[];
|
||||
deniedReason?: string;
|
||||
}
|
||||
|
||||
interface ComponentCatalog {
|
||||
schemaVersion: number;
|
||||
mantineVersion: string;
|
||||
customWidgetVersion: string;
|
||||
types: string[];
|
||||
globalProps: CatalogProp[];
|
||||
blockedCapabilities: Array<{ kind: string; name: string; reason: string }>;
|
||||
components: CatalogComponent[];
|
||||
}
|
||||
type ComponentCatalog = CustomJsxAuthoringCatalog;
|
||||
type CatalogComponent = CustomJsxComponentApi;
|
||||
type CatalogProp = CustomJsxPropDescriptor;
|
||||
|
||||
const PAGE_SIZE = 40;
|
||||
|
||||
@@ -395,10 +366,13 @@ function ComponentDetails({ id, component, types }: { id: string; component: Cat
|
||||
<div className={styles.details} id={id}>
|
||||
<div className={styles.detailsGrid}>
|
||||
<section>
|
||||
<h5>Usage example</h5>
|
||||
<pre className={styles.codeBlock}>
|
||||
<code>{buildUsageExample(component, types)}</code>
|
||||
</pre>
|
||||
<CustomWidgetCodeExample
|
||||
id={`${componentAnchor(component.name)}-usage`}
|
||||
label="Usage example"
|
||||
language="jsx"
|
||||
code={buildCustomJsxComponentUsageExample(component, { types })}
|
||||
height="180px"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -457,9 +431,12 @@ function ComponentDetails({ id, component, types }: { id: string; component: Cat
|
||||
|
||||
<details className={styles.rawComponent}>
|
||||
<summary>Raw component JSON</summary>
|
||||
<pre className={styles.codeBlock}>
|
||||
<code>{JSON.stringify(component, null, 2)}</code>
|
||||
</pre>
|
||||
<CustomWidgetCodeExample
|
||||
id={`${componentAnchor(component.name)}-json`}
|
||||
label="Component JSON"
|
||||
code={JSON.stringify(component, null, 2)}
|
||||
height="320px"
|
||||
/>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
@@ -518,47 +495,3 @@ function humanize(value: string) {
|
||||
function formatLiteral(value: string | number | boolean | null) {
|
||||
return typeof value === "string" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
|
||||
function buildUsageExample(component: CatalogComponent, types: string[]) {
|
||||
if (component.name === "RecursiveList") {
|
||||
return `<RecursiveList data={data.tree} childrenPath="children" keyPath="id" showLines>
|
||||
{(node, meta) => (
|
||||
<Group gap="xs">
|
||||
<Text>{node.label}</Text>
|
||||
{meta.hasChildren && <Badge>{meta.childCount}</Badge>}
|
||||
</Group>
|
||||
)}
|
||||
</RecursiveList>`;
|
||||
}
|
||||
|
||||
const props = component.props
|
||||
.filter((prop) => prop.required)
|
||||
.slice(0, 4)
|
||||
.map((prop) => `${prop.name}=${examplePropValue(prop, types[prop.typeRef] ?? "unknown")}`);
|
||||
if (component.bind) {
|
||||
props.unshift(
|
||||
`bind="value"`,
|
||||
`${component.bind.initialProp}=${bindingInitialValue(component.bind.type, component.bind.initialProp)}`,
|
||||
);
|
||||
}
|
||||
const propText = props.length > 0 ? ` ${props.join(" ")}` : "";
|
||||
return `<${component.name}${propText} />`;
|
||||
}
|
||||
|
||||
function bindingInitialValue(type: ComponentBindingType, initialProp: "defaultValue" | "defaultChecked") {
|
||||
if (initialProp === "defaultChecked") return "{false}";
|
||||
if (type === "string") return '""';
|
||||
if (type === "number") return "{0}";
|
||||
if (type === "boolean") return "{false}";
|
||||
return "{[]}";
|
||||
}
|
||||
|
||||
function examplePropValue(prop: CatalogProp, type: string) {
|
||||
const literal = prop.literalValues?.[0];
|
||||
if (literal !== undefined) return `{${formatLiteral(literal)}}`;
|
||||
if (type.includes("string")) return `"${prop.name}"`;
|
||||
if (type.includes("boolean")) return "{true}";
|
||||
if (type.includes("number")) return "{0}";
|
||||
if (type.includes("[]") || type.includes("Array<")) return "{[]}";
|
||||
return `{options.${prop.name}}`;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
WorkshopUser,
|
||||
} from "@homarr/workshop";
|
||||
import { validateWorkshopWidget, WorkshopClient, workshopExportFilename } from "@homarr/workshop";
|
||||
import { CUSTOM_WIDGET_SCHEMA } from "@homarr/custom-widgets/core";
|
||||
|
||||
import { CustomWidgetCodeExample } from "../custom-widget-code";
|
||||
import styles from "./workshop.module.css";
|
||||
@@ -31,6 +32,7 @@ export function WorkshopPage() {
|
||||
const [reportCategory, setReportCategory] = useState<WorkshopReport["category"]>("other");
|
||||
const [reportText, setReportText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const draftValidation = useMemo(() => validateWorkshopWidget(draft.content), [draft.content]);
|
||||
const reportCounts = useMemo(() => {
|
||||
@@ -41,6 +43,7 @@ export function WorkshopPage() {
|
||||
const selectedReports = selected ? reports.filter((report) => report.submission === selected.id) : [];
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextReports, nextItems] = await Promise.all([
|
||||
user?.isAdmin ? client.listReports() : Promise.resolve([]),
|
||||
@@ -58,6 +61,8 @@ export function WorkshopPage() {
|
||||
setError("");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "Workshop is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, search, sort, user?.isAdmin]);
|
||||
|
||||
@@ -252,7 +257,7 @@ export function WorkshopPage() {
|
||||
</select>
|
||||
</div>
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.grid} aria-busy={loading}>
|
||||
{items.map((item) => {
|
||||
const reportCount = reportCounts.get(item.id) ?? 0;
|
||||
return (
|
||||
@@ -273,6 +278,9 @@ export function WorkshopPage() {
|
||||
▲ {item.upvotes} · ▼ {item.downvotes}
|
||||
</span>
|
||||
</div>
|
||||
<span className={item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? styles.success : styles.error}>
|
||||
{item.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "Current" : "Unsupported"} · {item.widgetSchema}
|
||||
</span>
|
||||
{user?.isAdmin && reportCount > 0 && (
|
||||
<span className={styles.reportCount}>
|
||||
{reportCount} {reportCount === 1 ? "report" : "reports"}
|
||||
@@ -288,7 +296,8 @@ export function WorkshopPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
|
||||
{loading && items.length === 0 && <p className={styles.empty}>Loading Workshop…</p>}
|
||||
{!loading && items.length === 0 && <p className={styles.empty}>No widgets match this search.</p>}
|
||||
|
||||
{selected && (
|
||||
<div
|
||||
@@ -308,6 +317,9 @@ export function WorkshopPage() {
|
||||
▲ {selected.upvotes} · ▼ {selected.downvotes}
|
||||
</span>
|
||||
</div>
|
||||
<span className={selected.widgetSchema === CUSTOM_WIDGET_SCHEMA ? styles.success : styles.error}>
|
||||
{selected.widgetSchema === CUSTOM_WIDGET_SCHEMA ? "Current" : "Unsupported"} · {selected.widgetSchema}
|
||||
</span>
|
||||
{user?.isAdmin && selectedReports.length > 0 && (
|
||||
<section className={styles.moderation} aria-labelledby="submission-reports-title">
|
||||
<h3 id="submission-reports-title">
|
||||
|
||||
Reference in new issue
Block a user