fix(custom-widgets): refine v2 authoring
This commit is contained in:
124 files changed
+2089
-913
No files matched your search
@@ -1,16 +1,16 @@
|
||||
import { customJsxRequestSchema } from "../core";
|
||||
import { customJsxRequestSchema, getCustomJsxBindingType } from "../core";
|
||||
import { CUSTOM_JSX_LIMITS, validateCustomJsxTemplate } from "../jsx";
|
||||
|
||||
export type EditorDiagnosticCode =
|
||||
| "ast"
|
||||
| "bindingTypeConflict"
|
||||
| "dynamicRequestId"
|
||||
| "dynamicStateBinding"
|
||||
| "dynamicInputBinding"
|
||||
| "inlineRequestProps"
|
||||
| "invalidRequestManifest"
|
||||
| "missingRequestId"
|
||||
| "templateEmpty"
|
||||
| "templateTooLong"
|
||||
| "unknownStateBinding"
|
||||
| "unknownRequest";
|
||||
|
||||
export interface EditorDiagnostic {
|
||||
@@ -24,16 +24,15 @@ export interface EditorDiagnostic {
|
||||
|
||||
export interface AnalyzeJsxTemplateOptions {
|
||||
requestIds?: string[];
|
||||
stateKeys?: string[];
|
||||
}
|
||||
const networkComponentPattern = /<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/giu;
|
||||
const stateBindingPattern = /<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu;
|
||||
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,
|
||||
{ requestIds = [], stateKeys = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
{ requestIds = [] }: AnalyzeJsxTemplateOptions = {},
|
||||
): EditorDiagnostic[] {
|
||||
const diagnostics: EditorDiagnostic[] = [];
|
||||
if (!template.trim()) diagnostics.push({ code: "templateEmpty", severity: "error", line: 1, column: 1, index: 0 });
|
||||
@@ -71,24 +70,31 @@ export function analyzeJsxTemplate(
|
||||
diagnostics.push({ code: "missingRequestId", severity: "error", line, index: match.index });
|
||||
}
|
||||
}
|
||||
const knownState = new Set(stateKeys);
|
||||
for (const match of template.matchAll(stateBindingPattern)) {
|
||||
const stateName = match[1] ?? match[2];
|
||||
if (!stateName) {
|
||||
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: "dynamicStateBinding",
|
||||
code: "dynamicInputBinding",
|
||||
severity: "error",
|
||||
line: lineAt(template, match.index),
|
||||
index: match.index,
|
||||
});
|
||||
} else if (!knownState.has(stateName)) {
|
||||
continue;
|
||||
}
|
||||
const type = getCustomJsxBindingType(componentName);
|
||||
const existing = inputTypes.get(inputName);
|
||||
if (type && existing && existing !== type) {
|
||||
diagnostics.push({
|
||||
code: "unknownStateBinding",
|
||||
code: "bindingTypeConflict",
|
||||
severity: "error",
|
||||
line: lineAt(template, match.index),
|
||||
index: match.index,
|
||||
value: stateName,
|
||||
value: inputName,
|
||||
});
|
||||
} else if (type) {
|
||||
inputTypes.set(inputName, type);
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
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,
|
||||
editorView,
|
||||
messages,
|
||||
}: {
|
||||
diagnostics: EditorDiagnostic[];
|
||||
editorView: EditorView | null;
|
||||
messages: CustomWidgetEditorMessages;
|
||||
}) {
|
||||
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={() => {
|
||||
if (!editorView || diagnostic.index === undefined) return;
|
||||
const anchor = Math.min(diagnostic.index, editorView.state.doc.length);
|
||||
editorView.dispatch({ selection: { anchor }, scrollIntoView: true });
|
||||
editorView.focus();
|
||||
}}
|
||||
>
|
||||
<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,131 @@
|
||||
import { redo, undo } from "@codemirror/commands";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
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;
|
||||
editorView: EditorView | null;
|
||||
historyDepth: { undo: number; redo: number };
|
||||
formattedValue: string;
|
||||
copied: boolean;
|
||||
referenceOpened: boolean;
|
||||
onReferenceToggle(): void;
|
||||
onCopy(): void;
|
||||
}
|
||||
|
||||
export function CodeEditorToolbar({
|
||||
props,
|
||||
editorView,
|
||||
historyDepth,
|
||||
formattedValue,
|
||||
copied,
|
||||
referenceOpened,
|
||||
onReferenceToggle,
|
||||
onCopy,
|
||||
}: 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.messages.languageJson}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6}>
|
||||
{!props.readOnly && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowBackUp size={14} />}
|
||||
onClick={() => editorView && undo(editorView)}
|
||||
disabled={!editorView || historyDepth.undo === 0}
|
||||
>
|
||||
{props.messages.undo}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowForwardUp size={14} />}
|
||||
onClick={() => editorView && redo(editorView)}
|
||||
disabled={!editorView || 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={() => props.onChange(formattedValue)}
|
||||
disabled={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,58 @@
|
||||
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";
|
||||
diagnostics?: EditorDiagnostic[];
|
||||
error?: ReactNode;
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
starter?: string;
|
||||
completions?: Completion[];
|
||||
revealText?: string;
|
||||
revealKey?: number;
|
||||
readOnly?: boolean;
|
||||
reference?: CustomWidgetSchemaReferenceData;
|
||||
height?: string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.root {
|
||||
margin-top: 4px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
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: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
}
|
||||
|
||||
.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: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-gray-0));
|
||||
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: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
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,242 @@
|
||||
"use client";
|
||||
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { redoDepth, undoDepth } from "@codemirror/commands";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Badge, Collapse, Group, Input, Loader, Tabs, Text, useComputedColorScheme } from "@mantine/core";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import { createCustomJsxCompletionSource, customJsxComponentHover } from "./code-language";
|
||||
import { EditorDiagnostics } from "./code-editor-diagnostics";
|
||||
import { CodeEditorToolbar } from "./code-editor-toolbar";
|
||||
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 LazyCodeMirror = lazy(() => import("@uiw/react-codemirror").then((module) => ({ default: module.default })));
|
||||
const EMPTY_DIAGNOSTICS: NonNullable<CustomWidgetCodeEditorProps["diagnostics"]> = [];
|
||||
|
||||
export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
|
||||
const diagnostics = props.diagnostics ?? EMPTY_DIAGNOSTICS;
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [referenceOpened, setReferenceOpened] = useState(false);
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [editorView, setEditorView] = useState<EditorViewType | null>(null);
|
||||
const [historyDepth, setHistoryDepth] = useState({ undo: 0, redo: 0 });
|
||||
const colorScheme = useComputedColorScheme("light");
|
||||
useEffect(() => setMounted(true), []);
|
||||
const extensions = useMemo(() => {
|
||||
const diagnosticsExtension = linter((view) =>
|
||||
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.messages.diagnostic(diagnostic),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const accessibilityExtension = EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label });
|
||||
const editable = props.readOnly
|
||||
? [EditorView.editable.of(false), EditorView.contentAttributes.of({ tabindex: "0" })]
|
||||
: [];
|
||||
return props.language === "jsx"
|
||||
? [
|
||||
javascript({ jsx: true }),
|
||||
autocompletion({ override: [createCustomJsxCompletionSource(props.completions ?? [])] }),
|
||||
customJsxComponentHover,
|
||||
diagnosticsExtension,
|
||||
accessibilityExtension,
|
||||
...editable,
|
||||
]
|
||||
: [json(), diagnosticsExtension, accessibilityExtension, ...editable];
|
||||
}, [diagnostics, props.completions, props.id, props.label, props.language, props.messages, props.readOnly]);
|
||||
useEffect(() => {
|
||||
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();
|
||||
}, [editorView, props.revealKey, props.revealText]);
|
||||
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}
|
||||
editorView={editorView}
|
||||
historyDepth={historyDepth}
|
||||
formattedValue={formattedValue}
|
||||
copied={copied}
|
||||
referenceOpened={referenceOpened}
|
||||
onReferenceToggle={() => setReferenceOpened((value) => !value)}
|
||||
onCopy={() => void copy()}
|
||||
/>
|
||||
{props.reference && (
|
||||
<Collapse expanded={referenceOpened}>
|
||||
<CustomWidgetSchemaReference reference={props.reference} messages={props.messages} />
|
||||
</Collapse>
|
||||
)}
|
||||
<div className={classes.viewport}>
|
||||
{mounted ? (
|
||||
<Suspense fallback={<EditorLoader />}>
|
||||
<LazyCodeMirror
|
||||
id={`${props.id}-root`}
|
||||
value={props.value}
|
||||
onChange={props.readOnly ? () => undefined : props.onChange}
|
||||
placeholder={props.placeholder}
|
||||
extensions={extensions}
|
||||
theme={colorScheme}
|
||||
height={props.height ?? (props.language === "jsx" ? "320px" : "220px")}
|
||||
basicSetup={{
|
||||
autocompletion: props.language !== "jsx" && !props.readOnly,
|
||||
bracketMatching: true,
|
||||
closeBrackets: !props.readOnly,
|
||||
foldGutter: true,
|
||||
history: !props.readOnly,
|
||||
highlightActiveLine: !props.readOnly,
|
||||
highlightActiveLineGutter: !props.readOnly,
|
||||
indentOnInput: !props.readOnly,
|
||||
lineNumbers: true,
|
||||
}}
|
||||
onCreateEditor={setEditorView}
|
||||
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} editorView={editorView} messages={props.messages} />
|
||||
</Input.Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadOnlyCustomWidgetCode(props: Omit<CustomWidgetCodeEditorProps, "onChange" | "readOnly">) {
|
||||
return <CustomWidgetCodeEditor {...props} readOnly onChange={() => undefined} />;
|
||||
}
|
||||
|
||||
export function CustomWidgetSchemaReference({
|
||||
reference,
|
||||
messages,
|
||||
}: {
|
||||
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={`schema-reference-${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") {
|
||||
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,53 @@
|
||||
import type { Completion, CompletionSource } from "@codemirror/autocomplete";
|
||||
import { hoverTooltip } from "@codemirror/view";
|
||||
|
||||
import { customJsxComponentByName, customJsxTablerIconNames, enabledCustomJsxComponents } from "../core";
|
||||
|
||||
const componentCompletions: Completion[] = enabledCustomJsxComponents.map((component) => ({
|
||||
label: component.name,
|
||||
type: "class",
|
||||
detail: `${component.category} · ${component.safety}`,
|
||||
info: component.documentationUrl,
|
||||
}));
|
||||
|
||||
const propCompletions: Completion[] = [
|
||||
...new Set(enabledCustomJsxComponents.flatMap((component) => component.supportedProps)),
|
||||
]
|
||||
.filter((prop) => prop !== "key")
|
||||
.map((prop) => ({ label: `${prop}=`, type: "property", detail: "Mantine prop" }));
|
||||
|
||||
const iconCompletions: Completion[] = customJsxTablerIconNames.map((name) => ({
|
||||
label: `name="${name}"`,
|
||||
type: "enum",
|
||||
detail: "Safe Tabler icon",
|
||||
}));
|
||||
|
||||
export const createCustomJsxCompletionSource =
|
||||
(additional: Completion[]): CompletionSource =>
|
||||
(context) => {
|
||||
const word = context.matchBefore(/[A-Za-z](?:[\w."'_-]|\[|\])*/u);
|
||||
if (!word && !context.explicit) return null;
|
||||
return {
|
||||
from: word?.from ?? context.pos,
|
||||
options: [...componentCompletions, ...propCompletions, ...iconCompletions, ...additional],
|
||||
};
|
||||
};
|
||||
|
||||
export const customJsxComponentHover = hoverTooltip((view, position) => {
|
||||
const word = view.state.wordAt(position);
|
||||
if (!word) return null;
|
||||
const name = view.state.doc.sliceString(word.from, word.to);
|
||||
const component = customJsxComponentByName.get(name);
|
||||
if (!component || component.safety === "denied") return null;
|
||||
return {
|
||||
pos: word.from,
|
||||
end: word.to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.style.padding = "6px 8px";
|
||||
dom.textContent = `${component.name} · ${component.category} · ${component.documentationUrl}`;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -27,8 +27,6 @@ export const customWidgetFormSchema = z.object({
|
||||
requests: jsonArrayString,
|
||||
optionsSchema: jsonObjectString,
|
||||
defaultOptions: jsonObjectString,
|
||||
stateSchema: jsonObjectString,
|
||||
defaultState: 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() }),
|
||||
@@ -58,8 +56,6 @@ export const DEFAULT_CUSTOM_WIDGET_FORM_VALUES: CustomWidgetFormValues = {
|
||||
requests: JSON.stringify(CUSTOM_WIDGET_STARTER.requests, null, 2),
|
||||
optionsSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.optionsSchema, null, 2),
|
||||
defaultOptions: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultOptions, null, 2),
|
||||
stateSchema: JSON.stringify(CUSTOM_WIDGET_STARTER.stateSchema ?? {}, null, 2),
|
||||
defaultState: JSON.stringify(CUSTOM_WIDGET_STARTER.defaultState ?? {}, null, 2),
|
||||
template: CUSTOM_WIDGET_STARTER.template,
|
||||
secrets: [],
|
||||
};
|
||||
@@ -3,6 +3,8 @@ 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";
|
||||
|
||||
Reference in new issue
Block a user