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,11 +1,15 @@
|
||||
import { customJsxRequestSchema } from "../core";
|
||||
import { getCustomJsxBindingType } from "../core/component-registry";
|
||||
import { customJsxRequestSchema } from "../core/request-schema";
|
||||
import { z } from "zod/v4";
|
||||
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
| "ast"
|
||||
| "bindingTypeConflict"
|
||||
| "dynamicRequestId"
|
||||
| "dynamicInputBinding"
|
||||
| "inlineRequestProps"
|
||||
| "invalidRequestManifest"
|
||||
| "legacyNetworkProps"
|
||||
| "missingRequestId"
|
||||
| "templateEmpty"
|
||||
| "templateTooLong"
|
||||
@@ -21,16 +25,16 @@ export interface EditorDiagnostic {
|
||||
}
|
||||
|
||||
export interface AnalyzeJsxTemplateOptions {
|
||||
apiVersion?: 1 | 2;
|
||||
requestIds?: string[];
|
||||
}
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
|
||||
const inputBindingPattern = /<([A-Z][A-Za-z0-9.]*)(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
|
||||
const lineAt = (source: string, index: number) => source.slice(0, index).split("\n").length;
|
||||
export const CUSTOM_JSX_TEMPLATE_LIMIT = CUSTOM_JSX_LIMITS.templateLength;
|
||||
|
||||
export function analyzeJsxTemplate(
|
||||
template: string,
|
||||
{ apiVersion = 1, requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
{ requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
): EditorDiagnostic[] {
|
||||
const diagnostics: EditorDiagnostic[] = [];
|
||||
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
|
||||
@@ -46,13 +50,12 @@ export function analyzeJsxTemplate(
|
||||
value: entry.message,
|
||||
})),
|
||||
);
|
||||
if (apiVersion !== 2) return diagnostics;
|
||||
const knownIds = new Set(requestIds);
|
||||
for (const match of template.matchAll(networkComponentPattern)) {
|
||||
const props = match[2] ?? "";
|
||||
const line = lineAt(template, match.index);
|
||||
if (/\b(?:url|method|headers|body|onBody|offBody)\s*=/iu.test(props)) {
|
||||
diagnostics.push({ code: "legacyNetworkProps", severity: "error", line, index: match.index });
|
||||
diagnostics.push({ code: "inlineRequestProps", severity: "error", line, index: match.index });
|
||||
}
|
||||
const staticRequest = props.match(/\brequestId\s*=\s*["']([^"']+)["']/iu);
|
||||
if (staticRequest?.[1] && !knownIds.has(staticRequest[1])) {
|
||||
@@ -69,12 +72,39 @@ export function analyzeJsxTemplate(
|
||||
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
|
||||
}
|
||||
}
|
||||
const inputTypes = new Map<string, string>();
|
||||
for (const match of template.matchAll(inputBindingPattern)) {
|
||||
const componentName = match[1] ?? "";
|
||||
const inputName = match[2] ?? match[3];
|
||||
if (!inputName) {
|
||||
diagnostics.push({
|
||||
code: "dynamicInputBinding",
|
||||
severity: "error",
|
||||
line: lineAt(template, match.index),
|
||||
index: match.index,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const type = getCustomJsxBindingType(componentName);
|
||||
const existing = inputTypes.get(inputName);
|
||||
if (type && existing && existing !== type) {
|
||||
diagnostics.push({
|
||||
code: "bindingTypeConflict",
|
||||
severity: "error",
|
||||
line: lineAt(template, match.index),
|
||||
index: match.index,
|
||||
value: inputName,
|
||||
});
|
||||
} else if (type) {
|
||||
inputTypes.set(inputName, type);
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
|
||||
try {
|
||||
const result = customJsxRequestSchema.array().safeParse(JSON.parse(value) as unknown);
|
||||
const result = z.record(z.string(), customJsxRequestSchema).safeParse(JSON.parse(value) as unknown);
|
||||
if (result.success) return [];
|
||||
return result.error.issues.map((issue) => ({
|
||||
code: "invalidRequestManifest",
|
||||
@@ -87,11 +117,13 @@ export function analyzeRequestManifest(value: string): EditorDiagnostic[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRequestManifest(value: string): unknown[] {
|
||||
export function parseRequestManifest(value: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return [];
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Alert, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
import type { CustomWidgetEditorMessages } from "./code-editor-types";
|
||||
import classes from "./code-editor.module.css";
|
||||
|
||||
export function EditorDiagnostics({
|
||||
diagnostics,
|
||||
messages,
|
||||
onSelect,
|
||||
}: {
|
||||
diagnostics: EditorDiagnostic[];
|
||||
messages: CustomWidgetEditorMessages;
|
||||
onSelect(diagnostic: EditorDiagnostic): void;
|
||||
}) {
|
||||
if (diagnostics.length === 0) return null;
|
||||
return (
|
||||
<Stack component="ul" gap={6} mt="xs" pl={0} style={{ listStyle: "none" }} aria-label={messages.diagnosticsTitle}>
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li key={`${diagnostic.code}-${diagnostic.line ?? 0}-${index}`}>
|
||||
<UnstyledButton
|
||||
type="button"
|
||||
w="100%"
|
||||
className={classes.diagnosticButton}
|
||||
onClick={() => onSelect(diagnostic)}
|
||||
>
|
||||
<Alert
|
||||
color={diagnostic.severity === "error" ? "red" : "yellow"}
|
||||
variant="light"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
>
|
||||
<Text size="xs">{messages.diagnostic(diagnostic)}</Text>
|
||||
</Alert>
|
||||
</UnstyledButton>
|
||||
</li>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import {
|
||||
IconArrowBackUp,
|
||||
IconArrowForwardUp,
|
||||
IconCode,
|
||||
IconCopy,
|
||||
IconFileCode,
|
||||
IconSchema,
|
||||
IconWand,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { ComponentReference } from "./component-reference";
|
||||
import type { CustomWidgetCodeEditorProps } from "./code-editor-types";
|
||||
import classes from "./code-editor.module.css";
|
||||
|
||||
interface CodeEditorToolbarProps {
|
||||
props: CustomWidgetCodeEditorProps;
|
||||
editorCreated: boolean;
|
||||
historyDepth: { undo: number; redo: number };
|
||||
formattedValue: string;
|
||||
copied: boolean;
|
||||
referenceOpened: boolean;
|
||||
onReferenceToggle(): void;
|
||||
onCopy(): void;
|
||||
onUndo(): void;
|
||||
onRedo(): void;
|
||||
onFormat(): void;
|
||||
}
|
||||
|
||||
export function CodeEditorToolbar({
|
||||
props,
|
||||
editorCreated,
|
||||
historyDepth,
|
||||
formattedValue,
|
||||
copied,
|
||||
referenceOpened,
|
||||
onReferenceToggle,
|
||||
onCopy,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onFormat,
|
||||
}: CodeEditorToolbarProps) {
|
||||
const starter = props.starter;
|
||||
return (
|
||||
<Group className={classes.toolbar} justify="space-between" gap="xs" wrap="wrap">
|
||||
<Group gap={6}>
|
||||
<IconCode size={16} aria-hidden />
|
||||
<Text size="xs" fw={600}>
|
||||
{props.language === "jsx"
|
||||
? props.messages.languageJsx
|
||||
: props.language === "css"
|
||||
? "CSS"
|
||||
: props.messages.languageJson}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
{!props.readOnly && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowBackUp size={14} />}
|
||||
onClick={onUndo}
|
||||
disabled={!editorCreated || historyDepth.undo === 0}
|
||||
>
|
||||
{props.messages.undo}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowForwardUp size={14} />}
|
||||
onClick={onRedo}
|
||||
disabled={!editorCreated || historyDepth.redo === 0}
|
||||
>
|
||||
{props.messages.redo}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!props.readOnly && props.language === "jsx" && (
|
||||
<ComponentReference
|
||||
messages={{
|
||||
action: props.messages.components,
|
||||
search: props.messages.componentSearch,
|
||||
empty: props.messages.componentEmpty,
|
||||
count: props.messages.componentCount,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!props.readOnly && starter && props.value.trim().length === 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconFileCode size={14} />}
|
||||
onClick={() => props.onChange(starter)}
|
||||
>
|
||||
{props.messages.insertStarter}
|
||||
</Button>
|
||||
)}
|
||||
{!props.readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconWand size={14} />}
|
||||
onClick={onFormat}
|
||||
disabled={props.language !== "jsx" && formattedValue === props.value}
|
||||
>
|
||||
{props.messages.format}
|
||||
</Button>
|
||||
)}
|
||||
{props.reference && (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant={referenceOpened ? "light" : "subtle"}
|
||||
leftSection={<IconSchema size={14} />}
|
||||
onClick={onReferenceToggle}
|
||||
>
|
||||
{props.messages.schema}
|
||||
</Button>
|
||||
)}
|
||||
{props.readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCopy size={14} />}
|
||||
onClick={onCopy}
|
||||
>
|
||||
{copied ? props.messages.copied : props.messages.copy}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
|
||||
export interface CustomWidgetEditorMessages {
|
||||
languageJsx: string;
|
||||
languageJson: string;
|
||||
undo: string;
|
||||
redo: string;
|
||||
components: string;
|
||||
componentSearch: string;
|
||||
componentEmpty: string;
|
||||
componentCount(count: number): string;
|
||||
insertStarter: string;
|
||||
format: string;
|
||||
copy: string;
|
||||
copied: string;
|
||||
schema: string;
|
||||
schemaTab: string;
|
||||
minimalTab: string;
|
||||
fullTab: string;
|
||||
errors(count: number): string;
|
||||
warnings(count: number): string;
|
||||
ready: string;
|
||||
position(cursor: { line: number; column: number }): string;
|
||||
characters(count: number, limit?: number): string;
|
||||
diagnosticsTitle: string;
|
||||
diagnostic(diagnostic: EditorDiagnostic): string;
|
||||
}
|
||||
|
||||
export interface CustomWidgetSchemaReferenceData {
|
||||
schema: unknown;
|
||||
minimal: unknown;
|
||||
full: unknown;
|
||||
}
|
||||
|
||||
export interface CustomWidgetCodeEditorProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange(value: string): void;
|
||||
label: string;
|
||||
messages: CustomWidgetEditorMessages;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
language: "jsx" | "json" | "css";
|
||||
diagnostics?: EditorDiagnostic[];
|
||||
error?: ReactNode;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
starter?: string;
|
||||
completions?: Completion[];
|
||||
revealText?: string;
|
||||
revealKey?: number;
|
||||
insertText?: string;
|
||||
insertKey?: number;
|
||||
readOnly?: boolean;
|
||||
reference?: CustomWidgetSchemaReferenceData;
|
||||
height?: string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.root {
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-body);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.footer {
|
||||
padding: 7px 10px;
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
.footer {
|
||||
border-top: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
.viewport {
|
||||
max-height: min(62vh, 680px);
|
||||
overflow: auto;
|
||||
}
|
||||
.viewport :global(.cm-editor) {
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-text);
|
||||
font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.viewport :global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
.viewport :global(.cm-scroller) {
|
||||
line-height: 1.65;
|
||||
overflow: auto;
|
||||
}
|
||||
.viewport :global(.cm-gutters) {
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
}
|
||||
.diagnosticButton {
|
||||
min-height: 44px;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
.reference {
|
||||
border-bottom: 1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 48em) {
|
||||
.viewport {
|
||||
max-height: 52vh;
|
||||
}
|
||||
.toolbar,
|
||||
.footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { indentSelection, redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { lazy, Suspense, useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { EditorDiagnostics } from "./code-editor-diagnostics";
|
||||
import { CodeEditorToolbar } from "./code-editor-toolbar";
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
import type {
|
||||
CustomWidgetCodeEditorProps,
|
||||
CustomWidgetEditorMessages,
|
||||
CustomWidgetSchemaReferenceData,
|
||||
} from "./code-editor-types";
|
||||
import classes from "./code-editor.module.css";
|
||||
|
||||
export type {
|
||||
CustomWidgetCodeEditorProps,
|
||||
CustomWidgetEditorMessages,
|
||||
CustomWidgetSchemaReferenceData,
|
||||
} from "./code-editor-types";
|
||||
|
||||
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
|
||||
const LazyCodeMirror = lazy(() => import("./direct-code-mirror"));
|
||||
const editorViews = new Map<string, EditorViewType>();
|
||||
|
||||
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
const editorInstanceId = useId();
|
||||
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [referenceOpened, setReferenceOpened] = useState(false);
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [editorCreated, setEditorCreated] = useState(false);
|
||||
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
useEffect(() => {
|
||||
setEditorReady(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (!editorView || !props.revealText) return;
|
||||
const from = editorView.state.doc.toString().indexOf(props.revealText);
|
||||
if (from < 0) return;
|
||||
editorView.dispatch({
|
||||
selection: { anchor: from, head: from + props.revealText.length },
|
||||
effects: EditorView.scrollIntoView(from, { y: "center" }),
|
||||
});
|
||||
editorView.focus();
|
||||
}, [editorCreated, editorInstanceId, props.revealKey, props.revealText]);
|
||||
useEffect(() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (!editorView || !props.insertText) return;
|
||||
const selection = editorView.state.selection.main;
|
||||
editorView.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: props.insertText },
|
||||
selection: { anchor: selection.from + props.insertText.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
editorView.focus();
|
||||
}, [editorCreated, editorInstanceId, props.insertKey, props.insertText]);
|
||||
const handleCreateEditor = useCallback(
|
||||
(view: EditorViewType) => {
|
||||
editorViews.set(editorInstanceId, view);
|
||||
window.setTimeout(() => setEditorCreated(true), 0);
|
||||
},
|
||||
[editorInstanceId],
|
||||
);
|
||||
const handleDestroyEditor = useCallback(() => {
|
||||
editorViews.delete(editorInstanceId);
|
||||
}, [editorInstanceId]);
|
||||
const selectDiagnostic = useCallback(
|
||||
(diagnostic: EditorDiagnostic) => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
},
|
||||
[editorInstanceId],
|
||||
);
|
||||
const formattedValue = useMemo(() => formatCode(props.value, props.language), [props.language, props.value]);
|
||||
const errorCount = diagnostics.filter(({ severity }) => severity === "error").length;
|
||||
const warningCount = diagnostics.length - errorCount;
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2_000);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Input.Wrapper
|
||||
label={props.label}
|
||||
description={props.description}
|
||||
error={props.error}
|
||||
required={props.required}
|
||||
labelProps={{ htmlFor: props.id }}
|
||||
inputWrapperOrder={["label", "description", "input", "error"]}
|
||||
>
|
||||
<div className={classes.root}>
|
||||
<CodeEditorToolbar
|
||||
props={props}
|
||||
editorCreated={editorCreated}
|
||||
historyDepth={historyDepth}
|
||||
formattedValue={formattedValue}
|
||||
copied={copied}
|
||||
referenceOpened={referenceOpened}
|
||||
onReferenceToggle={() => setReferenceOpened((value) => !value)}
|
||||
onCopy={() => void copy()}
|
||||
onUndo={() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (editorView) undo(editorView);
|
||||
}}
|
||||
onRedo={() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (editorView) redo(editorView);
|
||||
}}
|
||||
onFormat={() => {
|
||||
const editorView = editorViews.get(editorInstanceId);
|
||||
if (props.language === "jsx" && editorView) {
|
||||
const selection = editorView.state.selection.main;
|
||||
editorView.dispatch({ selection: { anchor: 0, head: editorView.state.doc.length } });
|
||||
indentSelection(editorView);
|
||||
const documentLength = editorView.state.doc.length;
|
||||
editorView.dispatch({
|
||||
selection: {
|
||||
anchor: Math.min(selection.anchor, documentLength),
|
||||
head: Math.min(selection.head, documentLength),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
props.onChange(formattedValue);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{props.reference && (
|
||||
<Collapse expanded={referenceOpened}>
|
||||
<CustomWidgetSchemaReference
|
||||
id={`${props.id}-schema-reference`}
|
||||
reference={props.reference}
|
||||
messages={props.messages}
|
||||
/>
|
||||
</Collapse>
|
||||
)}
|
||||
<div className={classes.viewport}>
|
||||
{editorReady ? (
|
||||
<Suspense fallback={<EditorLoader />}>
|
||||
<LazyCodeMirror
|
||||
id={`${props.id}-root`}
|
||||
value={props.value}
|
||||
language={props.language}
|
||||
diagnostics={diagnostics}
|
||||
completions={props.completions ?? []}
|
||||
label={props.label}
|
||||
placeholder={props.placeholder}
|
||||
theme={colorScheme}
|
||||
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
|
||||
readOnly={props.readOnly ?? false}
|
||||
diagnosticMessage={props.messages.diagnostic}
|
||||
onChange={props.readOnly ? () => undefined : props.onChange}
|
||||
onCreateEditor={handleCreateEditor}
|
||||
onDestroyEditor={handleDestroyEditor}
|
||||
onUpdate={(update) => {
|
||||
if (!update.selectionSet && !update.docChanged) return;
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setCursor({ line: line.number, column: head - line.from + 1 });
|
||||
setHistoryDepth({ undo: undoDepth(update.state), redo: redoDepth(update.state) });
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<EditorLoader />
|
||||
)}
|
||||
</div>
|
||||
<Group className={classes.footer} justify="space-between" gap="xs">
|
||||
<Group gap={6}>
|
||||
{errorCount > 0 ? (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
{props.messages.errors(errorCount)}
|
||||
</Badge>
|
||||
) : warningCount > 0 ? (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
{props.messages.warnings(warningCount)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
<IconCheck size={13} color="var(--mantine-color-green-6)" aria-hidden />
|
||||
<Text size="xs" c="dimmed">
|
||||
{props.messages.ready}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{props.messages.position(cursor)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c={props.maxLength && props.value.length > props.maxLength ? "red" : "dimmed"}>
|
||||
{props.messages.characters(props.value.length, props.maxLength)}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
<EditorDiagnostics diagnostics={diagnostics} messages={props.messages} onSelect={selectDiagnostic} />
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadOnlyCustomWidgetCode(props: Omit<CustomWidgetCodeEditorProps, "onChange" | "readOnly">) {
|
||||
return <CustomWidgetCodeEditor {...props} readOnly onChange={() => undefined} />;
|
||||
}
|
||||
|
||||
export function CustomWidgetSchemaReference({
|
||||
id,
|
||||
reference,
|
||||
messages,
|
||||
}: {
|
||||
id: string;
|
||||
reference: CustomWidgetSchemaReferenceData;
|
||||
messages: CustomWidgetEditorMessages;
|
||||
}) {
|
||||
const values = { schema: reference.schema, minimal: reference.minimal, full: reference.full };
|
||||
const labels = { schema: messages.schemaTab, minimal: messages.minimalTab, full: messages.fullTab };
|
||||
return (
|
||||
<Tabs defaultValue="schema" className={classes.reference}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="schema">{messages.schemaTab}</Tabs.Tab>
|
||||
<Tabs.Tab value="minimal">{messages.minimalTab}</Tabs.Tab>
|
||||
<Tabs.Tab value="full">{messages.fullTab}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<Tabs.Panel key={key} value={key} pt="xs">
|
||||
<ReadOnlyCustomWidgetCode
|
||||
id={`${id}-${key}`}
|
||||
label={labels[key as keyof typeof labels]}
|
||||
language="json"
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
messages={messages}
|
||||
height="280px"
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorLoader() {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
function formatCode(value: string, language: "jsx" | "json" | "css") {
|
||||
if (language === "json") {
|
||||
try {
|
||||
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value
|
||||
.replace(/\r\n?/gu, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
|
||||
import { hoverTooltip } from "@codemirror/view";
|
||||
|
||||
import {
|
||||
customJsxAuthoringCatalog,
|
||||
customJsxCatalogComponentByName,
|
||||
getCustomJsxComponentProps,
|
||||
} from "../core/component-catalog";
|
||||
import { customJsxTablerIconNames } from "../core/tabler-icons";
|
||||
import { ROOT_BINDINGS } from "../jsx/analyzer-language";
|
||||
|
||||
const enabledCatalogComponents = customJsxAuthoringCatalog.components.filter(
|
||||
(component) => component.safety !== "denied",
|
||||
);
|
||||
|
||||
const componentCompletions: Completion[] = enabledCatalogComponents.map((component) => ({
|
||||
label: component.name,
|
||||
type: "class",
|
||||
detail: `${component.package} · ${component.category} · ${component.safety}`,
|
||||
info: component.description ?? component.documentationUrl,
|
||||
}));
|
||||
|
||||
export function getCustomJsxComponentPropCompletions(componentName: string): Completion[] {
|
||||
return getCustomJsxComponentProps(componentName)
|
||||
.filter((prop) => prop.name !== "key")
|
||||
.map((prop) => ({
|
||||
label: `${prop.name}=`,
|
||||
type: "property",
|
||||
detail: `${prop.type}${prop.required ? " · required" : ""}`,
|
||||
info: [prop.description, prop.literalValues?.length ? `Values: ${prop.literalValues.join(", ")}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
}));
|
||||
}
|
||||
|
||||
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
|
||||
label: `name="${name}"`,
|
||||
type: "enum",
|
||||
detail: "Safe Tabler icon",
|
||||
}));
|
||||
|
||||
const rootBindingCompletions: Completion[] = [...ROOT_BINDINGS].toSorted().map((label) => ({
|
||||
label,
|
||||
type: ["data", "status", "options", "inputs"].includes(label) ? "variable" : "function",
|
||||
detail: "Safe Custom JSX binding",
|
||||
}));
|
||||
|
||||
export const createCustomJsxCompletionSource =
|
||||
(additional: Completion[]): CompletionSource =>
|
||||
(context) => {
|
||||
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
const source = context.state.doc.toString();
|
||||
const componentName = activeOpeningComponent(source, context.pos);
|
||||
const contextualCompletions = componentName
|
||||
? [
|
||||
...getCustomJsxComponentPropCompletions(componentName),
|
||||
...(componentName === "TablerIcon" ? iconCompletions : []),
|
||||
]
|
||||
: componentCompletions;
|
||||
return {
|
||||
from: word?.from ?? context.pos,
|
||||
options: [...rootBindingCompletions, ...contextualCompletions, ...additional],
|
||||
};
|
||||
};
|
||||
|
||||
export const customJsxComponentHover = hoverTooltip((view, position) => {
|
||||
const source = view.state.doc.toString();
|
||||
const token = dottedTokenAt(source, position);
|
||||
if (!token) return null;
|
||||
const component = customJsxCatalogComponentByName.get(token.value);
|
||||
const activeComponentName = activeOpeningComponent(source, position);
|
||||
const prop = activeComponentName
|
||||
? getCustomJsxComponentProps(activeComponentName).find((candidate) => candidate.name === token.value)
|
||||
: undefined;
|
||||
if ((!component || component.safety === "denied") && !prop) return null;
|
||||
return {
|
||||
pos: token.from,
|
||||
end: token.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.style.padding = "6px 8px";
|
||||
dom.textContent = prop
|
||||
? `${activeComponentName}.${prop.name}: ${prop.type}${prop.required ? " · required" : ""}${prop.description ? ` — ${prop.description}` : ""}`
|
||||
: `${component?.name} · ${component?.package} · ${component?.category}${component?.description ? ` — ${component.description}` : ""}`;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function activeOpeningComponent(source: string, position: number): string | undefined {
|
||||
const match = source.slice(0, position).match(/<([A-Z][A-Za-z0-9.]*)\b[^<>]*$/u);
|
||||
const name = match?.[1];
|
||||
return name && customJsxCatalogComponentByName.get(name)?.safety !== "denied" ? name : undefined;
|
||||
}
|
||||
|
||||
function dottedTokenAt(source: string, position: number) {
|
||||
let from = position;
|
||||
let to = position;
|
||||
while (from > 0 && /[A-Za-z0-9._-]/u.test(source[from - 1] ?? "")) from -= 1;
|
||||
while (to < source.length && /[A-Za-z0-9._-]/u.test(source[to] ?? "")) to += 1;
|
||||
return from === to ? null : { from, to, value: source.slice(from, to) };
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Button, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { Accordion, Badge, Button, Code, Group, Popover, ScrollArea, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconComponents, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import { enabledCustomJsxComponents } from "../core/component-registry";
|
||||
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "../core/component-catalog";
|
||||
|
||||
export interface ComponentReferenceMessages {
|
||||
action: string;
|
||||
@@ -11,19 +11,26 @@ export interface ComponentReferenceMessages {
|
||||
count(count: number): string;
|
||||
}
|
||||
|
||||
const enabledComponents = customJsxAuthoringCatalog.components.filter((component) => component.safety !== "denied");
|
||||
|
||||
export function ComponentReference({ messages }: { messages: ComponentReferenceMessages }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const components = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return enabledCustomJsxComponents.filter(
|
||||
(component) =>
|
||||
normalized.length === 0 ||
|
||||
component.name.toLocaleLowerCase().includes(normalized) ||
|
||||
component.category.toLocaleLowerCase().includes(normalized),
|
||||
if (!normalized) return enabledComponents;
|
||||
return enabledComponents.filter((component) =>
|
||||
[
|
||||
component.name,
|
||||
component.package,
|
||||
component.category,
|
||||
component.description ?? "",
|
||||
...getCustomJsxComponentProps(component.name).map(({ name, type }) => `${name} ${type}`),
|
||||
].some((value) => value.toLocaleLowerCase().includes(normalized)),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<Popover width={320} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover width={560} position="bottom-end" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button type="button" size="compact-xs" variant="subtle" leftSection={<IconComponents size={14} />}>
|
||||
{messages.action}
|
||||
@@ -39,27 +46,66 @@ export function ComponentReference({ messages }: { messages: ComponentReferenceM
|
||||
aria-label={messages.search}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
/>
|
||||
<ScrollArea.Autosize mah={300} type="auto">
|
||||
<Stack gap={4}>
|
||||
{components.map((component) => (
|
||||
<Group key={component.name} justify="space-between" wrap="nowrap" py={3} px={4}>
|
||||
<Text size="xs" ff="monospace" fw={600}>
|
||||
{component.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{messages.empty}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<ScrollArea.Autosize mah={440} type="auto">
|
||||
<Accordion variant="separated" radius="sm">
|
||||
{components.map((component) => {
|
||||
const props = getCustomJsxComponentProps(component.name);
|
||||
return (
|
||||
<Accordion.Item key={component.name} value={component.name}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="xs">
|
||||
<Code>{component.name}</Code>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{component.bind && <Badge size="xs">bind: {component.bind.type}</Badge>}
|
||||
<Badge size="xs" variant="light" color={component.safety === "wrapped" ? "yellow" : "gray"}>
|
||||
{component.category}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap={6}>
|
||||
{component.description && <Text size="xs">{component.description}</Text>}
|
||||
<Text size="xs" c="dimmed">
|
||||
{component.package} · {props.length} props
|
||||
</Text>
|
||||
{component.blockedProps.map((prop) => (
|
||||
<Text key={prop.name} size="xs" c="red">
|
||||
{prop.name}: {prop.reason}
|
||||
</Text>
|
||||
))}
|
||||
<ScrollArea.Autosize mah={200} type="auto">
|
||||
<Stack gap={3}>
|
||||
{props.map((prop) => (
|
||||
<Group key={prop.name} gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Code>{prop.name}</Code>
|
||||
<Text size="xs" ff="monospace" c="dimmed" ta="right">
|
||||
{prop.type}
|
||||
{prop.required ? " · required" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
{component.accessibilityRequirements.map((requirement) => (
|
||||
<Text key={requirement} size="xs" c="dimmed">
|
||||
{requirement}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
{components.length === 0 && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{messages.empty}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.count(enabledCustomJsxComponents.length)}
|
||||
{messages.count(enabledComponents.length)} · Mantine {customJsxAuthoringCatalog.mantineVersion}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import { EditorState, StateEffect } from "@codemirror/state";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView, keymap, placeholder as codeMirrorPlaceholder } from "@codemirror/view";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
|
||||
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
|
||||
import type { EditorDiagnostic } from "./analyzer";
|
||||
|
||||
interface DirectCodeMirrorProps {
|
||||
value: string;
|
||||
language: "jsx" | "json" | "css";
|
||||
diagnostics: EditorDiagnostic[];
|
||||
completions: Completion[];
|
||||
id: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
height: string;
|
||||
theme: "light" | "dark";
|
||||
readOnly: boolean;
|
||||
diagnosticMessage(diagnostic: EditorDiagnostic): string;
|
||||
onChange(value: string): void;
|
||||
onCreateEditor(view: EditorView): void;
|
||||
onDestroyEditor(): void;
|
||||
onUpdate(update: ViewUpdate): void;
|
||||
}
|
||||
|
||||
interface EditorCallbacks {
|
||||
onChange(value: string): void;
|
||||
onUpdate(update: ViewUpdate): void;
|
||||
}
|
||||
|
||||
const editorViews = new Map<string, EditorView>();
|
||||
const editorCallbacks = new Map<string, EditorCallbacks>();
|
||||
const editorContainers = new Map<string, HTMLDivElement>();
|
||||
|
||||
export default function DirectCodeMirror(props: DirectCodeMirrorProps) {
|
||||
const instanceId = useId();
|
||||
const [initialProps] = useState(props);
|
||||
const [containerReady, setContainerReady] = useState(false);
|
||||
const setContainer = useCallback(
|
||||
(container: HTMLDivElement | null) => {
|
||||
if (!container) {
|
||||
editorContainers.delete(instanceId);
|
||||
return;
|
||||
}
|
||||
editorContainers.set(instanceId, container);
|
||||
setContainerReady(true);
|
||||
},
|
||||
[instanceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
editorCallbacks.set(instanceId, { onChange: props.onChange, onUpdate: props.onUpdate });
|
||||
}, [instanceId, props.onChange, props.onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const parent = editorContainers.get(instanceId);
|
||||
if (!parent) return;
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({ doc: initialProps.value, extensions: createExtensions(instanceId, initialProps) }),
|
||||
});
|
||||
editorViews.set(instanceId, view);
|
||||
initialProps.onCreateEditor(view);
|
||||
return () => {
|
||||
editorViews.delete(instanceId);
|
||||
editorCallbacks.delete(instanceId);
|
||||
initialProps.onDestroyEditor();
|
||||
view.destroy();
|
||||
};
|
||||
}, [containerReady, initialProps, instanceId]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = editorViews.get(instanceId);
|
||||
if (!view || view.state.doc.toString() === props.value) return;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: props.value } });
|
||||
}, [instanceId, props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = editorViews.get(instanceId);
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: StateEffect.reconfigure.of(createExtensions(instanceId, props)) });
|
||||
}, [instanceId, props]);
|
||||
|
||||
return <div ref={setContainer} />;
|
||||
}
|
||||
|
||||
function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
|
||||
const language =
|
||||
props.language === "jsx"
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [createCustomJsxCompletionSource(props.completions)] }),
|
||||
customJsxComponentHover,
|
||||
]
|
||||
: props.language === "css"
|
||||
? [css()]
|
||||
: [json()];
|
||||
return [
|
||||
basicSetup,
|
||||
...language,
|
||||
linter((view) =>
|
||||
props.diagnostics.map((diagnostic) => {
|
||||
const from = Math.min(diagnostic.index ?? 0, view.state.doc.length);
|
||||
return {
|
||||
from,
|
||||
to: Math.min(from + 1, view.state.doc.length),
|
||||
severity: diagnostic.severity,
|
||||
message: props.diagnosticMessage(diagnostic),
|
||||
};
|
||||
}),
|
||||
),
|
||||
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }),
|
||||
EditorView.editable.of(!props.readOnly),
|
||||
EditorState.readOnly.of(props.readOnly),
|
||||
keymap.of(props.readOnly ? [] : [indentWithTab]),
|
||||
props.placeholder ? codeMirrorPlaceholder(props.placeholder) : [],
|
||||
EditorView.theme({ "&": { height: props.height }, ".cm-scroller": { overflow: "auto" } }),
|
||||
props.theme === "dark" ? oneDark : [],
|
||||
EditorView.updateListener.of((update) => {
|
||||
const callbacks = editorCallbacks.get(instanceId);
|
||||
if (!callbacks) return;
|
||||
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
|
||||
callbacks.onUpdate(update);
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,105 +1,28 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { buildDisplayFormValues } from "../core";
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core";
|
||||
|
||||
const requiredScalarFields: Record<string, string[]> = {
|
||||
singleValue: ["jsonPath"],
|
||||
table: ["tablePath"],
|
||||
raw: ["rawJsonPath"],
|
||||
actionButton: ["buttonLabel"],
|
||||
customJsx: ["template"],
|
||||
};
|
||||
const requiredCollectionFields: Record<string, { collection: string; fields: string[] }> = {
|
||||
keyValue: { collection: "mappings", fields: ["label", "jsonPath"] },
|
||||
table: { collection: "columns", fields: ["header", "jsonPath"] },
|
||||
statGrid: { collection: "statGridItems", fields: ["label", "jsonPath"] },
|
||||
progressBars: { collection: "progressBars", fields: ["label", "valuePath"] },
|
||||
statusIndicator: { collection: "statusItems", fields: ["label", "jsonPath", "goodValues"] },
|
||||
countGrid: { collection: "countGridItems", fields: ["label", "jsonPath"] },
|
||||
};
|
||||
const jsonObjectString = z.string().superRefine((value, ctx) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
||||
} catch {
|
||||
ctx.addIssue({ code: "custom", message: "Must be a JSON object" });
|
||||
}
|
||||
});
|
||||
|
||||
const requiredString = z.string();
|
||||
export const customWidgetFormSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
description: requiredString,
|
||||
iconUrl: requiredString,
|
||||
url: z.string().trim().min(1),
|
||||
authType: requiredString,
|
||||
headerName: requiredString,
|
||||
method: requiredString,
|
||||
requestBody: requiredString,
|
||||
displayType: requiredString,
|
||||
jsonPath: requiredString,
|
||||
label: requiredString,
|
||||
unit: requiredString,
|
||||
valueSize: requiredString,
|
||||
labelPosition: requiredString,
|
||||
mappings: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
|
||||
kvLayout: requiredString,
|
||||
kvColumns: z.number(),
|
||||
tablePath: requiredString,
|
||||
columns: z.array(z.object({ header: requiredString, jsonPath: requiredString })),
|
||||
striped: z.boolean(),
|
||||
compact: z.boolean(),
|
||||
statGridItems: z.array(
|
||||
z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString, color: requiredString }),
|
||||
),
|
||||
statGridColumns: z.number(),
|
||||
cardStyle: requiredString,
|
||||
progressBars: z.array(
|
||||
z.object({
|
||||
label: requiredString,
|
||||
valuePath: requiredString,
|
||||
maxPath: requiredString,
|
||||
unit: requiredString,
|
||||
color: requiredString,
|
||||
}),
|
||||
),
|
||||
showPercentage: z.boolean(),
|
||||
barSize: requiredString,
|
||||
statusItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, goodValues: requiredString })),
|
||||
statusLayout: requiredString,
|
||||
dotSize: requiredString,
|
||||
countGridItems: z.array(z.object({ label: requiredString, jsonPath: requiredString, unit: requiredString })),
|
||||
countGridColumns: z.number(),
|
||||
countValueSize: requiredString,
|
||||
rawJsonPath: requiredString,
|
||||
rawMaxHeight: z.number(),
|
||||
buttonLabel: requiredString,
|
||||
buttonColor: requiredString,
|
||||
confirmText: requiredString,
|
||||
successMessage: requiredString,
|
||||
template: requiredString,
|
||||
jsxApiVersion: requiredString,
|
||||
networkScope: requiredString,
|
||||
requestManifest: requiredString,
|
||||
secrets: z.array(z.object({ kind: requiredString, value: requiredString, hasValue: z.boolean().optional() })),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
for (const field of requiredScalarFields[data.displayType] ?? []) {
|
||||
if (!data[field as keyof typeof data]) addRequiredIssue(ctx, [field], data[field as keyof typeof data]);
|
||||
}
|
||||
const collection = requiredCollectionFields[data.displayType];
|
||||
if (collection) {
|
||||
const entries = data[collection.collection as keyof typeof data];
|
||||
if (Array.isArray(entries))
|
||||
entries.forEach((entry, index) => {
|
||||
for (const field of collection.fields) {
|
||||
const value = (entry as Record<string, unknown>)[field];
|
||||
if (!value) addRequiredIssue(ctx, [collection.collection, index, field], value);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (data.displayType === "customJsx" && data.jsxApiVersion === "2" && !isJsonArray(data.requestManifest)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "invalidRequestManifest",
|
||||
input: data.requestManifest,
|
||||
path: ["requestManifest"],
|
||||
});
|
||||
}
|
||||
});
|
||||
export const customWidgetFormSchema = z.object({
|
||||
name: z.string().trim().min(1).max(128),
|
||||
description: z.string().max(512),
|
||||
iconUrl: z.union([z.literal(""), z.string().url()]),
|
||||
sources: jsonObjectString,
|
||||
requests: jsonObjectString,
|
||||
options: jsonObjectString,
|
||||
template: z.string().min(1).max(50_000),
|
||||
secrets: z.array(
|
||||
z.object({ sourceId: z.string(), kind: z.string(), value: z.string(), hasValue: z.boolean().optional() }),
|
||||
),
|
||||
});
|
||||
|
||||
export type CustomWidgetFormValues = z.infer<typeof customWidgetFormSchema>;
|
||||
|
||||
@@ -116,29 +39,13 @@ export const CUSTOM_WIDGET_AUTH_SECRET_FIELDS: Record<
|
||||
apiKeyQuery: [{ kind: "apiKey", labelKey: "apiKey", isPassword: true }],
|
||||
};
|
||||
|
||||
export const CUSTOM_WIDGET_AUTH_USES_HEADER_NAME: Record<string, boolean> = { apiKeyHeader: true, apiKeyQuery: true };
|
||||
|
||||
export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
iconUrl: "",
|
||||
url: "",
|
||||
authType: "none",
|
||||
headerName: "",
|
||||
method: "GET",
|
||||
requestBody: "",
|
||||
...buildDisplayFormValues("singleValue", {}),
|
||||
name: CUSTOM_WIDGET_STARTER.name,
|
||||
description: CUSTOM_WIDGET_STARTER.description ?? "",
|
||||
iconUrl: CUSTOM_WIDGET_STARTER.iconUrl ?? "",
|
||||
sources: JSON.stringify(CUSTOM_WIDGET_STARTER.sources, null, 2),
|
||||
requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2),
|
||||
options: JSON.stringify(CUSTOM_WIDGET_STARTER.options, null, 2),
|
||||
template: CUSTOM_WIDGET_STARTER.template,
|
||||
secrets: [],
|
||||
};
|
||||
|
||||
function addRequiredIssue(ctx: z.core.$RefinementCtx, path: PropertyKey[], input: unknown): void {
|
||||
ctx.addIssue({ code: "too_small", minimum: 1, origin: "string", inclusive: true, input, path });
|
||||
}
|
||||
|
||||
function isJsonArray(value: string): boolean {
|
||||
try {
|
||||
return Array.isArray(JSON.parse(value));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Alert, Badge, Button, Code, Group, Modal, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import type { ModalProps } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import type { ImportReview } from "../core/import";
|
||||
@@ -23,7 +25,11 @@ export interface ImportReviewDialogProps {
|
||||
opened: boolean;
|
||||
review: ImportReview | null;
|
||||
pending: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
messages: ImportReviewMessages;
|
||||
children?: ReactNode;
|
||||
stackId?: string;
|
||||
zIndex?: ModalProps["zIndex"];
|
||||
onClose(): void;
|
||||
onConfirm(): void;
|
||||
}
|
||||
@@ -39,9 +45,28 @@ function ImportFact({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportReviewDialog({ opened, review, pending, messages, onClose, onConfirm }: ImportReviewDialogProps) {
|
||||
export function ImportReviewDialog({
|
||||
opened,
|
||||
review,
|
||||
pending,
|
||||
confirmDisabled,
|
||||
messages,
|
||||
children,
|
||||
stackId,
|
||||
zIndex,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ImportReviewDialogProps) {
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={messages.title} centered size="lg">
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={messages.title}
|
||||
centered
|
||||
size="lg"
|
||||
stackId={stackId}
|
||||
zIndex={zIndex}
|
||||
>
|
||||
{review && (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
@@ -49,9 +74,9 @@ export function ImportReviewDialog({ opened, review, pending, messages, onClose,
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
|
||||
<ImportFact label={messages.name} value={review.name} />
|
||||
<ImportFact label={messages.origin} value={review.origin} />
|
||||
<ImportFact label={messages.authentication} value={review.authType} />
|
||||
<ImportFact label={messages.networkScope} value={review.networkScope} />
|
||||
<ImportFact label={messages.origin} value={review.origins.join(", ")} />
|
||||
<ImportFact label={messages.authentication} value={review.authTypes.join(", ")} />
|
||||
<ImportFact label={messages.networkScope} value={review.networkScopes.join(", ")} />
|
||||
</SimpleGrid>
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
@@ -89,11 +114,12 @@ export function ImportReviewDialog({ opened, review, pending, messages, onClose,
|
||||
<Text size="sm">{messages.actionWarningDescription}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{children}
|
||||
<Group justify="flex-end">
|
||||
<Button type="button" variant="default" onClick={onClose} disabled={pending}>
|
||||
{messages.cancel}
|
||||
</Button>
|
||||
<Button type="button" onClick={onConfirm} loading={pending}>
|
||||
<Button type="button" onClick={onConfirm} loading={pending} disabled={confirmDisabled}>
|
||||
{messages.confirm}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -3,15 +3,18 @@ export * from "./response-tree";
|
||||
export * from "./import-review";
|
||||
export * from "./ports";
|
||||
export * from "./component-reference";
|
||||
export * from "./code-editor";
|
||||
export * from "./code-language";
|
||||
export * from "./preview-error-boundary";
|
||||
export * from "./preview-response-panel";
|
||||
export * from "./preview-request-panel";
|
||||
export * from "./preview-diagnostics-panel";
|
||||
export * from "./preview-header";
|
||||
export * from "./preview-utils";
|
||||
export * from "./request-references";
|
||||
export * from "./source-setup";
|
||||
export {
|
||||
CUSTOM_WIDGET_AUTH_SECRET_FIELDS,
|
||||
CUSTOM_WIDGET_AUTH_USES_HEADER_NAME,
|
||||
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
customWidgetFormSchema,
|
||||
} from "./form-schema";
|
||||
|
||||
@@ -8,10 +8,23 @@ export interface PreviewErrorBoundaryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
retryLabel: string;
|
||||
resetKeys?: readonly unknown[];
|
||||
}
|
||||
|
||||
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, { hasError: boolean }> {
|
||||
public state = { hasError: false };
|
||||
interface PreviewErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
resetKeys?: readonly unknown[];
|
||||
}
|
||||
|
||||
export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, PreviewErrorBoundaryState> {
|
||||
public state: PreviewErrorBoundaryState = { hasError: false, resetKeys: this.props.resetKeys };
|
||||
public static getDerivedStateFromProps(
|
||||
props: PreviewErrorBoundaryProps,
|
||||
state: PreviewErrorBoundaryState,
|
||||
): Partial<PreviewErrorBoundaryState> | null {
|
||||
if (!resetKeysChanged(state.resetKeys, props.resetKeys)) return null;
|
||||
return { hasError: false, resetKeys: props.resetKeys };
|
||||
}
|
||||
public static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
@@ -40,3 +53,9 @@ export class PreviewErrorBoundary extends Component<PreviewErrorBoundaryProps, {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resetKeysChanged(previous: readonly unknown[] | undefined, current: readonly unknown[] | undefined) {
|
||||
if (previous === current) return false;
|
||||
if (!previous || !current || previous.length !== current.length) return true;
|
||||
return current.some((value, index) => !Object.is(value, previous[index]));
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
import { Alert, Badge, Button, Group, Loader, Popover, Stack, Switch, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle, IconNetwork, IconPlayerPlay } from "@tabler/icons-react";
|
||||
import { Alert, Badge, Button, Group, Loader, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPlayerPlay } from "@tabler/icons-react";
|
||||
|
||||
interface PreviewHeaderMessages {
|
||||
title: string;
|
||||
interactive: string;
|
||||
capabilitiesTitle: string;
|
||||
capabilitiesDescription: string;
|
||||
liveActions: string;
|
||||
liveActionsDescription: string;
|
||||
runTestFirst: string;
|
||||
simulated: string;
|
||||
test: string;
|
||||
mutationDisabled: string;
|
||||
staleTitle: string;
|
||||
@@ -19,94 +12,30 @@ interface PreviewHeaderMessages {
|
||||
interface PreviewHeaderProps {
|
||||
method: string;
|
||||
url: string;
|
||||
methods: string[];
|
||||
hasNamedActions: boolean;
|
||||
hasPreviewSession: boolean;
|
||||
liveActions: boolean;
|
||||
isUpdatingLiveActions: boolean;
|
||||
isTesting: boolean;
|
||||
isSampleStale: boolean;
|
||||
testError?: string | null;
|
||||
responseError?: string;
|
||||
responseStatus?: { status: number; statusText: string } | null;
|
||||
onTest: () => void;
|
||||
onSetLiveActions?: (enabled: boolean) => void;
|
||||
methodColor: (method: string) => string;
|
||||
messages: PreviewHeaderMessages;
|
||||
}
|
||||
|
||||
export function PreviewHeader({
|
||||
method,
|
||||
url,
|
||||
methods,
|
||||
hasNamedActions,
|
||||
hasPreviewSession,
|
||||
liveActions,
|
||||
isUpdatingLiveActions,
|
||||
isTesting,
|
||||
isSampleStale,
|
||||
testError,
|
||||
responseError,
|
||||
responseStatus,
|
||||
onTest,
|
||||
onSetLiveActions,
|
||||
methodColor,
|
||||
messages,
|
||||
}: PreviewHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Title order={5}>{messages.title}</Title>
|
||||
{methods.length > 0 && (
|
||||
<Popover width={300} position="bottom" withinPortal shadow="md">
|
||||
<Popover.Target>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconNetwork size={14} />}
|
||||
>
|
||||
{messages.interactive}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.capabilitiesTitle}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.capabilitiesDescription}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
{methods.map((requestMethod) => (
|
||||
<Badge key={requestMethod} size="xs" color={methodColor(requestMethod)} variant="light">
|
||||
{requestMethod}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{hasNamedActions && (
|
||||
<Stack gap={4} mt="xs">
|
||||
<Switch
|
||||
label={messages.liveActions}
|
||||
description={hasPreviewSession ? messages.liveActionsDescription : messages.runTestFirst}
|
||||
checked={liveActions}
|
||||
disabled={!hasPreviewSession || !onSetLiveActions || isUpdatingLiveActions}
|
||||
onChange={(event) => onSetLiveActions?.(event.currentTarget.checked)}
|
||||
/>
|
||||
{!liveActions && (
|
||||
<Badge size="xs" color="yellow" variant="light" style={{ alignSelf: "flex-start" }}>
|
||||
{messages.simulated}
|
||||
</Badge>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
</Group>
|
||||
<Title order={5}>{messages.title}</Title>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface PreviewNamedRequest {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
path: string;
|
||||
minimumBoardPermission?: string;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface PreviewJournalEntry {
|
||||
id: string;
|
||||
requestId: string;
|
||||
method: string;
|
||||
pathTemplate: string;
|
||||
path: string;
|
||||
simulated: boolean;
|
||||
status: number | null;
|
||||
durationMs: number;
|
||||
@@ -82,7 +82,7 @@ export function PreviewRequestPanel({
|
||||
{request.id}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1}>
|
||||
{request.pathTemplate}
|
||||
{request.path}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
@@ -111,7 +111,7 @@ export function PreviewRequestPanel({
|
||||
{entry.requestId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace" truncate>
|
||||
{entry.pathTemplate}
|
||||
{entry.path}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
|
||||
@@ -10,28 +10,70 @@ export const redactPreviewUrl = (value: string): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreviewNamedRequests = (displayConfig: Record<string, unknown>): PreviewNamedRequest[] => {
|
||||
if (!Array.isArray(displayConfig.requests)) return [];
|
||||
return displayConfig.requests.filter(
|
||||
(request): request is PreviewNamedRequest =>
|
||||
request !== null &&
|
||||
typeof request === "object" &&
|
||||
"id" in request &&
|
||||
typeof request.id === "string" &&
|
||||
"kind" in request &&
|
||||
(request.kind === "query" || request.kind === "action") &&
|
||||
"method" in request &&
|
||||
typeof request.method === "string" &&
|
||||
"pathTemplate" in request &&
|
||||
typeof request.pathTemplate === "string",
|
||||
);
|
||||
export const getPreviewNamedRequests = (definition: Record<string, unknown>): PreviewNamedRequest[] => {
|
||||
if (!definition.requests || typeof definition.requests !== "object" || Array.isArray(definition.requests)) return [];
|
||||
return Object.entries(definition.requests as Record<string, unknown>).flatMap(([id, candidate]) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return [];
|
||||
const request = candidate as Record<string, unknown>;
|
||||
if (
|
||||
(request.kind !== "query" && request.kind !== "action") ||
|
||||
typeof request.method !== "string" ||
|
||||
typeof request.path !== "string"
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
id,
|
||||
kind: request.kind,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
minimumBoardPermission: typeof request.permission === "string" ? request.permission : undefined,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
export const getPreviewLegacyMethods = (template: string): string[] => {
|
||||
const methods = new Set<string>();
|
||||
for (const match of template.matchAll(/<(?:SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu)) {
|
||||
const method = match[1]?.match(/\bmethod\s*=\s*["'](GET|POST|PUT|PATCH|DELETE)["']/iu)?.[1];
|
||||
methods.add(method?.toUpperCase() ?? (match[0].startsWith("<SubFetch") ? "GET" : "POST"));
|
||||
export type CustomWidgetAccessibilityIssue = "imageAlt" | "actionIconLabel" | "inputLabel";
|
||||
|
||||
export function analyzeCustomWidgetAccessibility(template: string): CustomWidgetAccessibilityIssue[] {
|
||||
const issues = new Set<CustomWidgetAccessibilityIssue>();
|
||||
const elements = template.matchAll(/<([A-Z][A-Za-z0-9.]*)\b([^>]*)>/gu);
|
||||
const inputComponents = new Set([
|
||||
"Autocomplete",
|
||||
"Checkbox",
|
||||
"ColorInput",
|
||||
"DateInput",
|
||||
"FileInput",
|
||||
"JsonInput",
|
||||
"MultiSelect",
|
||||
"NumberInput",
|
||||
"PasswordInput",
|
||||
"Radio",
|
||||
"Select",
|
||||
"Slider",
|
||||
"Switch",
|
||||
"TagsInput",
|
||||
"TextInput",
|
||||
"Textarea",
|
||||
"TimeInput",
|
||||
]);
|
||||
for (const match of elements) {
|
||||
const component = match[1] ?? "";
|
||||
const props = match[2] ?? "";
|
||||
if (component === "Image" && !hasProp(props, "alt")) issues.add("imageAlt");
|
||||
if (component === "ActionIcon" && !hasAnyProp(props, ["aria-label", "title"])) issues.add("actionIconLabel");
|
||||
if (inputComponents.has(component) && !hasAnyProp(props, ["label", "aria-label", "aria-labelledby"])) {
|
||||
issues.add("inputLabel");
|
||||
}
|
||||
}
|
||||
return [...methods];
|
||||
};
|
||||
return [...issues];
|
||||
}
|
||||
|
||||
function hasAnyProp(props: string, names: string[]) {
|
||||
return names.some((name) => hasProp(props, name));
|
||||
}
|
||||
|
||||
function hasProp(props: string, name: string) {
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
return new RegExp(`(?:^|\\s)${escapedName}\\s*=`, "u").test(props);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { customWidgetDefinitionSchema } from "../core/custom-jsx-schema";
|
||||
import { customWidgetIdentifierSchema } from "../core/request-schema";
|
||||
import type { HomarrCustomWidgetV2 } from "../core/custom-jsx-schema";
|
||||
|
||||
export function renameCustomWidgetRequest(
|
||||
definition: HomarrCustomWidgetV2,
|
||||
currentId: string,
|
||||
nextId: string,
|
||||
): HomarrCustomWidgetV2 {
|
||||
const parsedId = customWidgetIdentifierSchema.safeParse(nextId);
|
||||
if (!parsedId.success) throw new Error(parsedId.error.issues[0]?.message ?? "Invalid request ID");
|
||||
if (!definition.requests[currentId]) throw new Error(`Unknown request '${currentId}'`);
|
||||
if (currentId !== nextId && definition.requests[nextId]) {
|
||||
throw new Error(`Request '${nextId}' already exists`);
|
||||
}
|
||||
|
||||
const candidate = {
|
||||
...definition,
|
||||
requests: Object.fromEntries(
|
||||
Object.entries(definition.requests).map(([id, request]) => [
|
||||
id === currentId ? nextId : id,
|
||||
{ ...request, invalidates: request.invalidates?.map((entry) => (entry === currentId ? nextId : entry)) },
|
||||
]),
|
||||
),
|
||||
options: replaceDynamicOptionRequest(definition.options, currentId, nextId),
|
||||
template: replaceTemplateRequestReferences(definition.template, currentId, nextId),
|
||||
};
|
||||
return customWidgetDefinitionSchema.parse(candidate);
|
||||
}
|
||||
|
||||
export function renameCustomWidgetOption(
|
||||
definition: HomarrCustomWidgetV2,
|
||||
currentName: string,
|
||||
nextName: string,
|
||||
): HomarrCustomWidgetV2 {
|
||||
const parsedName = customWidgetIdentifierSchema.safeParse(nextName);
|
||||
if (!parsedName.success) throw new Error(parsedName.error.issues[0]?.message ?? "Invalid option name");
|
||||
if (!definition.options[currentName]) throw new Error(`Unknown option '${currentName}'`);
|
||||
if (currentName !== nextName && definition.options[nextName]) throw new Error(`Option '${nextName}' already exists`);
|
||||
|
||||
return customWidgetDefinitionSchema.parse({
|
||||
...definition,
|
||||
options: Object.fromEntries(
|
||||
Object.entries(definition.options).map(([name, option]) => [name === currentName ? nextName : name, option]),
|
||||
),
|
||||
requests: Object.fromEntries(
|
||||
Object.entries(definition.requests).map(([id, request]) => [
|
||||
id,
|
||||
{
|
||||
...request,
|
||||
path: request.path.replaceAll(`{option:${currentName}}`, `{option:${nextName}}`),
|
||||
query: replaceOptionReferences(request.query, currentName, nextName),
|
||||
body: replaceOptionReferences(request.body, currentName, nextName),
|
||||
},
|
||||
]),
|
||||
),
|
||||
template: replaceTemplateOptionReferences(definition.template, currentName, nextName),
|
||||
});
|
||||
}
|
||||
|
||||
function replaceDynamicOptionRequest<T>(value: T, currentId: string, nextId: string): T {
|
||||
if (Array.isArray(value)) return value.map((entry) => replaceDynamicOptionRequest(entry, currentId, nextId)) as T;
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
const record = value as Record<string, unknown>;
|
||||
const replaced = Object.fromEntries(
|
||||
Object.entries(record).map(([key, child]) => [key, replaceDynamicOptionRequest(child, currentId, nextId)]),
|
||||
);
|
||||
if (record.request === currentId && "valuePath" in record && "labelPath" in record) replaced.request = nextId;
|
||||
return replaced as T;
|
||||
}
|
||||
|
||||
function replaceTemplateRequestReferences(template: string, currentId: string, nextId: string) {
|
||||
const escaped = escapeRegExp(currentId);
|
||||
return template
|
||||
.replace(new RegExp(`(requestId\\s*=\\s*["'])${escaped}(["'])`, "gu"), `$1${nextId}$2`)
|
||||
.replace(new RegExp(`((?:data|status)\\s*\\[\\s*["'])${escaped}(["']\\s*\\])`, "gu"), `$1${nextId}$2`)
|
||||
.replace(new RegExp(`((?:data|status)\\.)${escaped}\\b`, "gu"), `$1${nextId}`);
|
||||
}
|
||||
|
||||
function replaceOptionReferences<T>(value: T, currentName: string, nextName: string): T {
|
||||
if (Array.isArray(value)) return value.map((entry) => replaceOptionReferences(entry, currentName, nextName)) as T;
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 1 && entries[0]?.[0] === "$option" && entries[0][1] === currentName) {
|
||||
return { $option: nextName } as T;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
entries.map(([key, child]) => [key, replaceOptionReferences(child, currentName, nextName)]),
|
||||
) as T;
|
||||
}
|
||||
|
||||
function replaceTemplateOptionReferences(template: string, currentName: string, nextName: string) {
|
||||
const escaped = escapeRegExp(currentName);
|
||||
return template
|
||||
.replace(new RegExp(`(options\\s*\\[\\s*["'])${escaped}(["']\\s*\\])`, "gu"), `$1${nextName}$2`)
|
||||
.replace(new RegExp(`(options\\.)${escaped}\\b`, "gu"), `$1${nextName}`);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Alert, Badge, Checkbox, Group, PasswordInput, Select, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { IconCheck, IconKey, IconServer, IconX } from "@tabler/icons-react";
|
||||
|
||||
import type {
|
||||
CustomWidgetSecretKind,
|
||||
CustomWidgetSource,
|
||||
CustomWidgetSourceSetup,
|
||||
CustomWidgetSourceUrlIssue,
|
||||
} from "../core";
|
||||
import { getCustomWidgetSourceSetupIssue } from "../core";
|
||||
|
||||
export interface CustomWidgetSourceSetupValue {
|
||||
baseUrl: string;
|
||||
networkScope: CustomWidgetSource["networkScope"];
|
||||
urlConfirmed: boolean;
|
||||
secrets: Partial<Record<CustomWidgetSecretKind, string>>;
|
||||
}
|
||||
|
||||
export interface CustomWidgetSourceSetupMessages {
|
||||
title: string;
|
||||
description: string;
|
||||
suggestedUrl: string;
|
||||
baseUrl: string;
|
||||
networkScope: string;
|
||||
authentication: string;
|
||||
confirmUrl: string;
|
||||
ready: string;
|
||||
needsUrl: string;
|
||||
credentialsMissing: string;
|
||||
credentialsOptional: string;
|
||||
configured: string;
|
||||
secret(kind: CustomWidgetSecretKind): string;
|
||||
urlError(issue: CustomWidgetSourceUrlIssue): string;
|
||||
}
|
||||
|
||||
export interface CustomWidgetSourceSetupPanelProps {
|
||||
setups: CustomWidgetSourceSetup[];
|
||||
values: Record<string, CustomWidgetSourceSetupValue>;
|
||||
messages: CustomWidgetSourceSetupMessages;
|
||||
onChange(sourceId: string, value: CustomWidgetSourceSetupValue): void;
|
||||
}
|
||||
|
||||
export function createCustomWidgetSourceSetupValues(
|
||||
setups: readonly CustomWidgetSourceSetup[],
|
||||
): Record<string, CustomWidgetSourceSetupValue> {
|
||||
return Object.fromEntries(
|
||||
setups.map((setup) => [
|
||||
setup.sourceId,
|
||||
{
|
||||
baseUrl: setup.baseUrl,
|
||||
networkScope: setup.networkScope,
|
||||
urlConfirmed: !setup.requiresUrlConfirmation,
|
||||
secrets: {} as Partial<Record<CustomWidgetSecretKind, string>>,
|
||||
} satisfies CustomWidgetSourceSetupValue,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function isCustomWidgetSourceSetupReady(
|
||||
setups: readonly CustomWidgetSourceSetup[],
|
||||
values: Record<string, CustomWidgetSourceSetupValue>,
|
||||
) {
|
||||
return setups.every((setup) => {
|
||||
const value = values[setup.sourceId];
|
||||
return Boolean(value && !getCustomWidgetSourceSetupIssue(value) && value.urlConfirmed);
|
||||
});
|
||||
}
|
||||
|
||||
export function CustomWidgetSourceSetupPanel({
|
||||
setups,
|
||||
values,
|
||||
messages,
|
||||
onChange,
|
||||
}: CustomWidgetSourceSetupPanelProps) {
|
||||
if (setups.length === 0) return null;
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Alert icon={<IconServer size={16} />}>
|
||||
<Text fw={600} size="sm">
|
||||
{messages.title}
|
||||
</Text>
|
||||
<Text size="sm">{messages.description}</Text>
|
||||
</Alert>
|
||||
{setups.map((setup) => {
|
||||
const value = values[setup.sourceId] ?? createCustomWidgetSourceSetupValues([setup])[setup.sourceId];
|
||||
if (!value) return null;
|
||||
const issue = getCustomWidgetSourceSetupIssue(value);
|
||||
const missingCredentials = setup.credentialFields.filter(
|
||||
(field) => !field.configured && !value.secrets[field.kind]?.trim(),
|
||||
);
|
||||
const urlReady = !issue && value.urlConfirmed;
|
||||
return (
|
||||
<Stack
|
||||
key={setup.sourceId}
|
||||
gap="sm"
|
||||
p="md"
|
||||
style={{
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>{setup.sourceName}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{setup.sourceId}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Badge
|
||||
variant="light"
|
||||
color={urlReady ? "green" : "red"}
|
||||
leftSection={urlReady ? <IconCheck size={11} /> : <IconX size={11} />}
|
||||
>
|
||||
{urlReady ? messages.ready : messages.needsUrl}
|
||||
</Badge>
|
||||
{missingCredentials.length > 0 && (
|
||||
<Badge variant="light" color="yellow" leftSection={<IconKey size={11} />}>
|
||||
{messages.credentialsMissing}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.suggestedUrl}: {setup.suggestedBaseUrl}
|
||||
</Text>
|
||||
<TextInput
|
||||
label={messages.baseUrl}
|
||||
type="url"
|
||||
value={value.baseUrl}
|
||||
error={issue ? messages.urlError(issue) : undefined}
|
||||
onChange={(event) =>
|
||||
onChange(setup.sourceId, { ...value, baseUrl: event.currentTarget.value, urlConfirmed: true })
|
||||
}
|
||||
/>
|
||||
<Group grow align="start">
|
||||
<Select
|
||||
label={messages.networkScope}
|
||||
data={["public", "private", "loopback"]}
|
||||
value={value.networkScope}
|
||||
allowDeselect={false}
|
||||
onChange={(networkScope) =>
|
||||
networkScope &&
|
||||
onChange(setup.sourceId, {
|
||||
...value,
|
||||
networkScope: networkScope as CustomWidgetSource["networkScope"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput label={messages.authentication} value={setup.authType} readOnly />
|
||||
</Group>
|
||||
{setup.requiresUrlConfirmation && value.baseUrl === setup.suggestedBaseUrl && (
|
||||
<Checkbox
|
||||
checked={value.urlConfirmed}
|
||||
label={messages.confirmUrl}
|
||||
onChange={(event) => onChange(setup.sourceId, { ...value, urlConfirmed: event.currentTarget.checked })}
|
||||
/>
|
||||
)}
|
||||
{setup.credentialFields.map((field) => {
|
||||
const Input = field.kind === "username" ? TextInput : PasswordInput;
|
||||
return (
|
||||
<Input
|
||||
key={field.kind}
|
||||
label={messages.secret(field.kind)}
|
||||
description={field.destination}
|
||||
value={value.secrets[field.kind] ?? ""}
|
||||
placeholder={field.configured ? messages.configured : undefined}
|
||||
leftSection={<IconKey size={15} />}
|
||||
autoComplete="off"
|
||||
onChange={(event) =>
|
||||
onChange(setup.sourceId, {
|
||||
...value,
|
||||
secrets: { ...value.secrets, [field.kind]: event.currentTarget.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{missingCredentials.length > 0 && (
|
||||
<Text size="xs" c="yellow">
|
||||
{messages.credentialsOptional}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user