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,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Switch } from "@mantine/core";
|
||||
import { IconCheck, IconPlayerPlay, IconPower, IconRefresh, IconTrash } from "@tabler/icons-react";
|
||||
@@ -8,7 +8,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
import { normalizeParams } from "./data";
|
||||
import { MigrationRequiredAlert } from "./status";
|
||||
import { RequestIdRequiredAlert } from "./status";
|
||||
import type { CustomJsxRuntimeParams, CustomWidgetRequestResult } from "./types";
|
||||
|
||||
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
|
||||
@@ -59,7 +59,8 @@ function useActionExecutor() {
|
||||
export interface ActionButtonProps {
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
label: string;
|
||||
label?: string;
|
||||
children?: ReactNode;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
@@ -67,21 +68,17 @@ export interface ActionButtonProps {
|
||||
successMessage?: string;
|
||||
errorMessage?: string;
|
||||
icon?: string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: string;
|
||||
fullWidth?: boolean | string;
|
||||
}
|
||||
|
||||
export function ActionButton(props: ActionButtonProps) {
|
||||
const { runtime, mutation } = useActionExecutor();
|
||||
const params = normalizeParams(props.params);
|
||||
const title = props.label ?? (typeof props.children === "string" ? props.children : props.requestId) ?? "Action";
|
||||
const Icon = props.icon ? ICON_MAP[props.icon.toLowerCase()] : undefined;
|
||||
const mustConfirm = runtime.requestCapabilities.some(
|
||||
(capability) => capability.id === props.requestId && capability.kind === "action" && capability.method === "DELETE",
|
||||
const capability = runtime.requestCapabilities.find(
|
||||
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
|
||||
);
|
||||
const run = async (confirmed: boolean) => {
|
||||
if (!props.requestId || !params) return;
|
||||
@@ -90,7 +87,7 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
const success = result.ok;
|
||||
runtime.port.notify({
|
||||
kind: success ? "success" : "error",
|
||||
title: props.label,
|
||||
title,
|
||||
message: success
|
||||
? (props.successMessage ??
|
||||
(result.simulated ? runtime.messages.actionSimulated : runtime.messages.actionCompleted))
|
||||
@@ -100,22 +97,34 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
await runtime.port.invalidate({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
targets: props.invalidate ?? [],
|
||||
targets: result.invalidates ?? capability?.invalidates ?? [],
|
||||
});
|
||||
} catch {
|
||||
runtime.port.notify({
|
||||
kind: "error",
|
||||
title: props.label,
|
||||
title,
|
||||
message: props.errorMessage ?? runtime.messages.requestFailed,
|
||||
});
|
||||
}
|
||||
};
|
||||
const click = async () => {
|
||||
const confirmMessage = props.confirmMessage ?? (mustConfirm ? runtime.messages.confirmDelete : undefined);
|
||||
if (confirmMessage && !(await runtime.port.confirm({ title: props.label, message: confirmMessage }))) return;
|
||||
const confirmMessage =
|
||||
props.confirmMessage ??
|
||||
capability?.confirmation?.message ??
|
||||
(capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
|
||||
if (
|
||||
confirmMessage &&
|
||||
!(await runtime.port.confirm({
|
||||
title: capability?.confirmation?.title ?? title,
|
||||
message: confirmMessage,
|
||||
confirmLabel: capability?.confirmation?.confirmLabel,
|
||||
destructive: capability?.confirmation?.destructive ?? capability?.method === "DELETE",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(Boolean(confirmMessage));
|
||||
};
|
||||
if (!props.requestId) return <MigrationRequiredAlert />;
|
||||
if (!props.requestId) return <RequestIdRequiredAlert />;
|
||||
return (
|
||||
<Button
|
||||
color={props.color ?? "blue"}
|
||||
@@ -127,27 +136,23 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
disabled={
|
||||
parseBool(props.disabled) || runtime.isEditMode || (!runtime.itemId && !runtime.previewSessionId) || !params
|
||||
}
|
||||
fullWidth={parseBool(props.fullWidth)}
|
||||
>
|
||||
{props.label}
|
||||
{props.children ?? props.label ?? props.requestId}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToggleSwitchProps {
|
||||
requestId?: string;
|
||||
onParams?: CustomJsxRuntimeParams;
|
||||
offParams?: CustomJsxRuntimeParams;
|
||||
enabledParams?: CustomJsxRuntimeParams;
|
||||
disabledParams?: CustomJsxRuntimeParams;
|
||||
initialValue?: boolean | string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
errorMessage?: string;
|
||||
invalidate?: string[];
|
||||
disabled?: boolean | string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
onBody?: string;
|
||||
offBody?: string;
|
||||
}
|
||||
|
||||
export function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
@@ -155,19 +160,39 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
const initial = parseBool(props.initialValue);
|
||||
const [checked, setChecked] = useState(initial);
|
||||
const locked = useRef(false);
|
||||
const onParams = normalizeParams(props.onParams);
|
||||
const offParams = normalizeParams(props.offParams);
|
||||
const enabledParams = normalizeParams(props.enabledParams);
|
||||
const disabledParams = normalizeParams(props.disabledParams);
|
||||
const capability = runtime.requestCapabilities.find(
|
||||
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!locked.current) setChecked(initial);
|
||||
}, [initial]);
|
||||
const change = async (next: boolean) => {
|
||||
const params = next ? onParams : offParams;
|
||||
const params = next ? enabledParams : disabledParams;
|
||||
if (!props.requestId || !params || locked.current) return;
|
||||
const confirmation = capability?.confirmation;
|
||||
const confirmMessage =
|
||||
confirmation?.message ?? (capability?.method === "DELETE" ? runtime.messages.confirmDelete : undefined);
|
||||
if (
|
||||
confirmMessage &&
|
||||
!(await runtime.port.confirm({
|
||||
title: confirmation?.title ?? props.label ?? runtime.messages.toggle,
|
||||
message: confirmMessage,
|
||||
confirmLabel: confirmation?.confirmLabel,
|
||||
destructive: confirmation?.destructive ?? capability?.method === "DELETE",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
locked.current = true;
|
||||
const previous = checked;
|
||||
setChecked(next);
|
||||
try {
|
||||
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed: false });
|
||||
const result = await mutation.mutateAsync({
|
||||
requestId: props.requestId,
|
||||
params,
|
||||
confirmed: confirmMessage !== undefined,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setChecked(previous);
|
||||
runtime.port.notify({
|
||||
@@ -179,7 +204,7 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
await runtime.port.invalidate({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
targets: props.invalidate ?? [],
|
||||
targets: result.invalidates ?? capability?.invalidates ?? [],
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -193,7 +218,7 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
locked.current = false;
|
||||
}
|
||||
};
|
||||
if (!props.requestId) return <MigrationRequiredAlert />;
|
||||
if (!props.requestId) return <RequestIdRequiredAlert />;
|
||||
return (
|
||||
<Switch
|
||||
label={props.label}
|
||||
@@ -206,8 +231,8 @@ export function ToggleSwitch(props: ToggleSwitchProps) {
|
||||
mutation.isPending ||
|
||||
runtime.isEditMode ||
|
||||
(!runtime.itemId && !runtime.previewSessionId) ||
|
||||
!onParams ||
|
||||
!offParams
|
||||
!enabledParams ||
|
||||
!disabledParams
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { ActionIcon, Code, Group, ScrollArea, SimpleGrid, Stack, Table, Text, Tooltip } from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
|
||||
import type { CustomWidgetDisplayData } from "./display-types";
|
||||
|
||||
export function KeyValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }> | undefined) ?? [];
|
||||
const content = entries.map((entry, index) => (
|
||||
<Group key={index} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm" c="dimmed">
|
||||
{entry.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{String(entry.value ?? "—")}
|
||||
{entry.unit ? ` ${entry.unit}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
));
|
||||
return data.layout === "grid" ? (
|
||||
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
|
||||
{content}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{content}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const columns = (data.columns as string[] | undefined) ?? [];
|
||||
const rows = (data.rows as unknown[][] | undefined) ?? [];
|
||||
return (
|
||||
<ScrollArea>
|
||||
<Table striped={data.striped !== false} highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map((column, index) => (
|
||||
<Table.Th key={index} py={data.compact ? 4 : undefined}>
|
||||
{column}
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<Table.Tr key={rowIndex}>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<Table.Td key={cellIndex} py={data.compact ? 2 : undefined}>
|
||||
{String(cell ?? "—")}
|
||||
</Table.Td>
|
||||
))}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusIndicatorDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }> | undefined) ?? [];
|
||||
const size = ({ sm: 8, md: 10, lg: 14 } as Record<string, number>)[String(data.dotSize ?? "md")] ?? 10;
|
||||
const content = items.map((item, index) => (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ml="auto">
|
||||
{item.value}
|
||||
</Text>
|
||||
</Group>
|
||||
));
|
||||
return data.layout === "grid" ? (
|
||||
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
|
||||
{content}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Stack h="100%" justify="center" gap="xs" p="sm">
|
||||
{content}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function openJsonInBrowser(value: unknown) {
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: "application/json" }));
|
||||
window.open(url);
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
|
||||
export function createRawDisplay(openJsonLabel: string) {
|
||||
return function RawDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
return (
|
||||
<Stack gap={4} p="xs">
|
||||
<Group justify="flex-end">
|
||||
<Tooltip label={openJsonLabel}>
|
||||
<ActionIcon
|
||||
aria-label={openJsonLabel}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => openJsonInBrowser(data.data)}
|
||||
>
|
||||
<IconExternalLink size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<ScrollArea mah={Number(data.maxHeight ?? 300)}>
|
||||
<Code block style={{ fontSize: 12 }}>
|
||||
{JSON.stringify(data.data, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { ComponentType, ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Badge, Box, Group, Popover, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle, IconNetwork } from "@tabler/icons-react";
|
||||
import { Component, useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { Alert, Box, Stack, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { renderSafeJsx } from "../jsx/interpreter";
|
||||
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
|
||||
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
|
||||
import type { CustomJsxRequestCapability } from "./types";
|
||||
|
||||
const EMPTY_RECORD: Record<string, never> = {};
|
||||
|
||||
const methodColors: Readonly<Record<string, string>> = {
|
||||
GET: "blue",
|
||||
POST: "orange",
|
||||
@@ -19,15 +23,14 @@ const permissions = new Set(["view", "modify", "full"]);
|
||||
|
||||
export interface CustomJsxRendererMessages {
|
||||
noTemplate: string;
|
||||
interactive: string;
|
||||
networkCapabilities: string;
|
||||
templateWarnings(count: number): string;
|
||||
}
|
||||
|
||||
export interface CustomJsxRendererProps {
|
||||
template: string;
|
||||
data: unknown;
|
||||
requestCapabilities: unknown;
|
||||
status?: Record<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
components: Readonly<Record<string, ComponentType<never>>>;
|
||||
createBindings(data: unknown): Readonly<Record<string, unknown>>;
|
||||
messages: CustomJsxRendererMessages;
|
||||
@@ -55,17 +58,31 @@ export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapabi
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
method: record.method,
|
||||
trigger: record.trigger === "load" ? "load" : "manual",
|
||||
minimumBoardPermission: record.minimumBoardPermission,
|
||||
confirmation:
|
||||
record.confirmation && typeof record.confirmation === "object"
|
||||
? (record.confirmation as CustomJsxRequestCapability["confirmation"])
|
||||
: undefined,
|
||||
invalidates: Array.isArray(record.invalidates)
|
||||
? record.invalidates.filter((entry): entry is string => typeof entry === "string")
|
||||
: [],
|
||||
} as CustomJsxRequestCapability,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
class RendererErrorBoundary extends Component<
|
||||
{ children: ReactNode; onError(error: Error): void },
|
||||
{ error: Error | null }
|
||||
{ children: ReactNode; resetKey: string; onError(error: Error): void },
|
||||
{ error: Error | null; resetKey: string }
|
||||
> {
|
||||
public state = { error: null } as { error: Error | null };
|
||||
public state = { error: null, resetKey: "" } as { error: Error | null; resetKey: string };
|
||||
public static getDerivedStateFromProps(
|
||||
props: Readonly<{ resetKey: string }>,
|
||||
state: Readonly<{ error: Error | null; resetKey: string }>,
|
||||
) {
|
||||
return props.resetKey === state.resetKey ? null : { error: null, resetKey: props.resetKey };
|
||||
}
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
@@ -80,22 +97,76 @@ class RendererErrorBoundary extends Component<
|
||||
function ErrorAlert({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs" fw={700}>
|
||||
RUNTIME_RENDER_ERROR
|
||||
</Text>
|
||||
<Text size="xs">{error.message}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomJsxRenderer({
|
||||
export function CustomJsxRenderer(props: CustomJsxRendererProps) {
|
||||
if (!props.template.trim())
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{props.messages.noTemplate}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
return <CustomJsxRendererSession key={props.template} {...props} />;
|
||||
}
|
||||
|
||||
function CustomJsxRendererSession({
|
||||
template,
|
||||
data,
|
||||
requestCapabilities: rawCapabilities,
|
||||
status = EMPTY_RECORD,
|
||||
options = EMPTY_RECORD,
|
||||
components,
|
||||
createBindings,
|
||||
messages,
|
||||
}: CustomJsxRendererProps) {
|
||||
const inputScopeId = useId();
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const bindings = useMemo(() => createBindings(data), [createBindings, data]);
|
||||
const capabilities = useMemo(() => parseRequestCapabilities(rawCapabilities), [rawCapabilities]);
|
||||
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
|
||||
const [inputs, setInputs] = useState<Record<string, WidgetInputValue>>({});
|
||||
const [inputTypes, setInputTypes] = useState<Record<string, WidgetInputType>>({});
|
||||
const registerInput = useCallback((name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
|
||||
setInputTypes((current) => {
|
||||
const existing = current[name];
|
||||
if (existing === type) return current;
|
||||
if (existing && existing !== type) {
|
||||
setBindingErrors((errors) => {
|
||||
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
|
||||
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
|
||||
});
|
||||
return current;
|
||||
}
|
||||
return { ...current, [name]: type };
|
||||
});
|
||||
setInputs((current) => (Object.hasOwn(current, name) ? current : { ...current, [name]: initialValue }));
|
||||
}, []);
|
||||
const setInputValue = useCallback(
|
||||
(name: string, type: WidgetInputType, value: WidgetInputValue) => {
|
||||
const existing = inputTypes[name];
|
||||
if (existing && existing !== type) {
|
||||
setBindingErrors((errors) => {
|
||||
const message = `BINDING_TYPE_CONFLICT: '${name}' is bound as both ${existing} and ${type}`;
|
||||
return errors.includes(message) ? errors : [...errors.slice(0, 4), message];
|
||||
});
|
||||
return;
|
||||
}
|
||||
setInputTypes((current) => (current[name] === type ? current : { ...current, [name]: type }));
|
||||
setInputs((current) => (Object.is(current[name], value) ? current : { ...current, [name]: value }));
|
||||
},
|
||||
[inputTypes],
|
||||
);
|
||||
const bindings = useMemo(
|
||||
() => ({ ...createBindings(data), status, options, inputs }),
|
||||
[createBindings, data, inputs, options, status],
|
||||
);
|
||||
const boundaryKey = useMemo(() => createBoundaryKey(template, bindings), [bindings, template]);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
return { ...renderSafeJsx({ template, components, bindings }), error: null };
|
||||
@@ -117,72 +188,31 @@ export function CustomJsxRenderer({
|
||||
if (rendered.error) handleError(rendered.error);
|
||||
}, [handleError, rendered]);
|
||||
|
||||
if (!template.trim())
|
||||
return (
|
||||
<Alert color="gray" variant="light" p="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.noTemplate}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
{capabilities.length > 0 && (
|
||||
<Group justify="flex-end" mb={4}>
|
||||
<Popover width={320} position="bottom-end" withinPortal={false} shadow="md">
|
||||
<Popover.Target>
|
||||
<Badge
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
color="gray"
|
||||
variant="light"
|
||||
leftSection={<IconNetwork size={12} />}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{messages.interactive}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
{messages.networkCapabilities}
|
||||
</Text>
|
||||
{capabilities.map((capability) => (
|
||||
<Group key={capability.id} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" ff="monospace" truncate>
|
||||
{capability.id}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" color={methodColors[capability.method] ?? "gray"} variant="light">
|
||||
{capability.method}
|
||||
</Badge>
|
||||
<Badge size="xs" color="gray" variant="outline">
|
||||
{capability.minimumBoardPermission}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
)}
|
||||
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
|
||||
{rendered.error ? (
|
||||
<ErrorAlert error={rendered.error} />
|
||||
) : (
|
||||
<RendererErrorBoundary key={template} onError={handleError}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
<CustomJsxInputsProvider
|
||||
scopeId={inputScopeId}
|
||||
inputs={inputs}
|
||||
inputTypes={inputTypes}
|
||||
registerInput={registerInput}
|
||||
setInputValue={setInputValue}
|
||||
>
|
||||
<RendererErrorBoundary resetKey={boundaryKey} onError={handleError}>
|
||||
{rendered.node}
|
||||
</RendererErrorBoundary>
|
||||
</CustomJsxInputsProvider>
|
||||
)}
|
||||
</Box>
|
||||
{parseErrors.length > 0 && (
|
||||
{[...parseErrors, ...bindingErrors].length > 0 && (
|
||||
<Alert color="yellow" variant="light" p="xs" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{messages.templateWarnings(parseErrors.length)}
|
||||
{messages.templateWarnings(parseErrors.length + bindingErrors.length)}
|
||||
</Text>
|
||||
{parseErrors.map((message) => (
|
||||
{[...parseErrors, ...bindingErrors].map((message) => (
|
||||
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
|
||||
{message}
|
||||
</Text>
|
||||
@@ -192,3 +222,10 @@ export function CustomJsxRenderer({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function createBoundaryKey(template: string, bindings: Readonly<Record<string, unknown>>) {
|
||||
let hash = 0;
|
||||
const value = `${template}\0${JSON.stringify(bindings)}`;
|
||||
for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
||||
return `${template.length}:${hash}`;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { CustomWidgetDisplayAdapters, CustomWidgetDisplayComponent } from "./display-types";
|
||||
import { createRawDisplay, KeyValueDisplay, StatusIndicatorDisplay, TableDisplay } from "./collection-displays";
|
||||
import { CountGridDisplay, ProgressBarsDisplay, SingleValueDisplay, StatGridDisplay } from "./metric-displays";
|
||||
|
||||
export function createCustomWidgetDisplayComponents(
|
||||
adapters: CustomWidgetDisplayAdapters,
|
||||
): Record<string, CustomWidgetDisplayComponent> {
|
||||
return {
|
||||
singleValue: SingleValueDisplay,
|
||||
keyValue: KeyValueDisplay,
|
||||
table: TableDisplay,
|
||||
statGrid: StatGridDisplay,
|
||||
progressBars: ProgressBarsDisplay,
|
||||
statusIndicator: StatusIndicatorDisplay,
|
||||
countGrid: CountGridDisplay,
|
||||
raw: createRawDisplay(adapters.openJsonLabel),
|
||||
actionButton: adapters.actionButton,
|
||||
customJsx: adapters.customJsx,
|
||||
} satisfies Record<string, CustomWidgetDisplayComponent>;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export type CustomWidgetDisplayData = Readonly<Record<string, unknown>>;
|
||||
export type CustomWidgetDisplayComponent = ComponentType<{ data: CustomWidgetDisplayData }>;
|
||||
|
||||
export interface CustomWidgetDisplayAdapters {
|
||||
actionButton: CustomWidgetDisplayComponent;
|
||||
customJsx: CustomWidgetDisplayComponent;
|
||||
openJsonLabel: string;
|
||||
}
|
||||
@@ -3,12 +3,10 @@ export { CustomWidgetRuntimeProvider, useCustomWidgetRuntime, useWidgetDefinitio
|
||||
export { SubData } from "./data";
|
||||
export { RefreshButton } from "./refresh-button";
|
||||
export { SubFetch } from "./sub-fetch";
|
||||
export { createCustomWidgetDisplayComponents } from "./display-registry";
|
||||
export { CustomJsxRenderer, CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./custom-jsx-renderer";
|
||||
export type { CustomJsxRendererMessages, CustomJsxRendererProps } from "./custom-jsx-renderer";
|
||||
export type { ActionButtonProps, ToggleSwitchProps } from "./actions";
|
||||
export type { SubDataProps } from "./data";
|
||||
export type { RefreshButtonProps } from "./refresh-button";
|
||||
export type { SubFetchMetadata, SubFetchProps } from "./sub-fetch";
|
||||
export type * from "./display-types";
|
||||
export type * from "./types";
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Card, Flex, Group, Progress, SimpleGrid, Stack, Text, Title } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import type { CustomWidgetDisplayData } from "./display-types";
|
||||
|
||||
export function SingleValueDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const size = String(data.valueSize ?? "lg");
|
||||
const labelAbove = data.labelPosition === "above";
|
||||
const label = data.label ? (
|
||||
<Text c="dimmed" size="sm">
|
||||
{String(data.label)}
|
||||
</Text>
|
||||
) : null;
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="xs">
|
||||
{labelAbove && label}
|
||||
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
|
||||
{String(data.value ?? "—")}
|
||||
{data.unit ? ` ${String(data.unit)}` : ""}
|
||||
</Title>
|
||||
{!labelAbove && label}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricItem {
|
||||
label: string;
|
||||
unit: string;
|
||||
color: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function StatGridCard({ item, cardStyle }: { item: MetricItem; cardStyle: string }) {
|
||||
const { ref, height, width } = useElementSize();
|
||||
const isWide = width > height + 20;
|
||||
const background = cardStyle === "outline" ? "transparent" : `var(--mantine-color-${item.color}-light)`;
|
||||
return (
|
||||
<Card ref={ref} p="sm" radius="md" bg={background} withBorder={cardStyle === "outline"} h="100%">
|
||||
<Flex h="100%" align="center" justify="center" direction={isWide ? "row" : "column"} gap={isWide ? 8 : 4}>
|
||||
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
|
||||
<Text size="md" fw={700} ta="center" lh={1.1}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
{height > 38 && (
|
||||
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const items = (data.items as MetricItem[] | undefined) ?? [];
|
||||
const columns = Number(data.columns ?? 2);
|
||||
return (
|
||||
<SimpleGrid
|
||||
cols={columns}
|
||||
spacing="xs"
|
||||
p="xs"
|
||||
h="100%"
|
||||
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<StatGridCard key={index} item={item} cardStyle={String(data.cardStyle ?? "filled")} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBarsDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const bars = (data.bars as Array<MetricItem & { value: number; max?: number }> | undefined) ?? [];
|
||||
const sizes: Record<string, number> = { sm: 8, md: 14, lg: 22 };
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="sm" p="sm">
|
||||
{bars.map((bar, index) => {
|
||||
const max = bar.max ?? 100;
|
||||
const percentage = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
|
||||
const value =
|
||||
data.showPercentage !== false ? `${percentage.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`;
|
||||
return (
|
||||
<Stack key={index} gap={4}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" fw={500}>
|
||||
{bar.label}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size={sizes[String(data.barSize ?? "md")] ?? 14}
|
||||
color={bar.color}
|
||||
radius="sm"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountGridDisplay({ data }: { data: CustomWidgetDisplayData }) {
|
||||
const items = (data.items as MetricItem[] | undefined) ?? [];
|
||||
return (
|
||||
<SimpleGrid cols={Number(data.columns ?? 2)} spacing="xs" p="sm" h="100%">
|
||||
{items.map((item, index) => (
|
||||
<Stack key={index} align="center" justify="center" gap={0}>
|
||||
<Text size={String(data.valueSize ?? "md")} fw={700}>
|
||||
{String(item.value ?? "—")}
|
||||
{item.unit ? ` ${item.unit}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" ta="center">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
@@ -13,7 +13,7 @@ export interface RefreshButtonProps {
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function RefreshButton({ label, color = "blue", variant = "light", size = "sm" }: RefreshButtonProps) {
|
||||
export function RefreshButton({ label, color = "gray", variant = "subtle", size = "sm" }: RefreshButtonProps) {
|
||||
const runtime = useCustomWidgetRuntime();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const refresh = async () => {
|
||||
@@ -30,16 +30,16 @@ export function RefreshButton({ label, color = "blue", variant = "light", size =
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Button
|
||||
<ActionIcon
|
||||
aria-label={label ?? runtime.messages.refresh}
|
||||
color={color}
|
||||
variant={variant as never}
|
||||
size={size as never}
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => void refresh()}
|
||||
loading={loading}
|
||||
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
|
||||
>
|
||||
{label ?? runtime.messages.refresh}
|
||||
</Button>
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
|
||||
export function MigrationRequiredAlert() {
|
||||
export function RequestIdRequiredAlert() {
|
||||
const { messages } = useCustomWidgetRuntime();
|
||||
return (
|
||||
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
|
||||
<Text size="xs">{messages.migrationRequired}</Text>
|
||||
<Text size="xs">{messages.requestIdRequired}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -2,49 +2,50 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Code, Skeleton, Text } from "@mantine/core";
|
||||
import { Alert, Box, Button, Code, Skeleton, Text } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
import { formatDisplayValue, getByPath, normalizeParams, SubFetchDataContext } from "./data";
|
||||
import { MigrationRequiredAlert } from "./status";
|
||||
import { RequestIdRequiredAlert } from "./status";
|
||||
import type { CustomJsxRuntimeParams } from "./types";
|
||||
|
||||
export interface SubFetchMetadata {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
statusText?: string;
|
||||
loading: false;
|
||||
error?: undefined;
|
||||
}
|
||||
|
||||
export interface SubFetchProps {
|
||||
requestId?: string;
|
||||
params?: CustomJsxRuntimeParams;
|
||||
refreshInterval?: number;
|
||||
children?: ReactNode;
|
||||
render?: (data: unknown, metadata: SubFetchMetadata) => ReactNode;
|
||||
children?: ReactNode | ((data: unknown, metadata: SubFetchMetadata) => ReactNode);
|
||||
loadingLabel?: string;
|
||||
errorMessage?: string;
|
||||
fallback?: ReactNode;
|
||||
triggerContent?: ReactNode;
|
||||
triggerAriaLabel?: string;
|
||||
path?: string;
|
||||
as?: "json" | "text";
|
||||
trigger?: "auto" | "manual";
|
||||
/** @deprecated Inline targets are inert in JSX API v2. */
|
||||
url?: string;
|
||||
/** @deprecated Request manifests own the method, body, and headers. */
|
||||
method?: string;
|
||||
body?: string;
|
||||
headers?: string;
|
||||
}
|
||||
|
||||
export function SubFetch(props: SubFetchProps) {
|
||||
const { itemId, previewSessionId, port, messages } = useCustomWidgetRuntime();
|
||||
const { itemId, previewSessionId, queriesDisabled, port, messages, setQueryState } = useCustomWidgetRuntime();
|
||||
const [manualRun, setManualRun] = useState(false);
|
||||
const params = useMemo(() => normalizeParams(props.params), [props.params]);
|
||||
const paramsKey = useMemo(() => JSON.stringify(params), [params]);
|
||||
useEffect(() => setManualRun(false), [paramsKey, props.requestId, props.trigger]);
|
||||
const enabled = Boolean(
|
||||
(itemId || previewSessionId) && props.requestId && params && (props.trigger !== "manual" || manualRun),
|
||||
!queriesDisabled &&
|
||||
(itemId || previewSessionId) &&
|
||||
props.requestId &&
|
||||
params &&
|
||||
(props.trigger !== "manual" || manualRun),
|
||||
);
|
||||
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
|
||||
const query = useQuery({
|
||||
@@ -55,17 +56,74 @@ export function SubFetch(props: SubFetchProps) {
|
||||
retry: 2,
|
||||
refetchInterval: refreshMs,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Manual queries are local to their SubFetch instance. Publishing them by request ID makes
|
||||
// repeated cards compete for the same data slot and can remount the active query mid-request.
|
||||
if (!props.requestId || !setQueryState || props.trigger === "manual") return;
|
||||
if (!enabled) {
|
||||
setQueryState(props.requestId, null);
|
||||
return;
|
||||
}
|
||||
const result = query.data;
|
||||
setQueryState(props.requestId, {
|
||||
data: result?.data ?? null,
|
||||
status: {
|
||||
loading: query.isFetching && !result,
|
||||
ok: result?.ok,
|
||||
status: result?.status,
|
||||
statusText: result?.statusText,
|
||||
error: result?.error ?? (query.error ? messages.requestFailed : undefined),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
enabled,
|
||||
messages.requestFailed,
|
||||
props.requestId,
|
||||
props.trigger,
|
||||
query.data,
|
||||
query.error,
|
||||
query.isFetching,
|
||||
setQueryState,
|
||||
]);
|
||||
|
||||
if (!props.requestId) return <MigrationRequiredAlert />;
|
||||
if (!itemId && !previewSessionId) return props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>;
|
||||
if (!props.requestId) return <RequestIdRequiredAlert />;
|
||||
if (queriesDisabled) return props.trigger === "manual" ? (props.triggerContent ?? null) : null;
|
||||
if (!itemId && !previewSessionId)
|
||||
return props.trigger === "manual" && props.triggerContent
|
||||
? props.triggerContent
|
||||
: (props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>);
|
||||
if (!params) return <RequestAlert message={messages.invalidParams} />;
|
||||
if (props.trigger === "manual" && !manualRun) {
|
||||
if (props.triggerContent) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-label={props.triggerAriaLabel ?? messages.loadRequest}
|
||||
onClick={() => setManualRun(true)}
|
||||
style={{
|
||||
appearance: "none",
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
padding: 0,
|
||||
textAlign: "inherit",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{props.triggerContent}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
|
||||
{messages.loadRequest}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (!props.children && props.path === undefined && props.as === undefined) return null;
|
||||
|
||||
const result = query.data;
|
||||
const requestError =
|
||||
@@ -88,7 +146,12 @@ export function SubFetch(props: SubFetchProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const content = renderContent(props, result?.data, result);
|
||||
const content = renderContent(props, result?.data, {
|
||||
ok: result?.ok ?? false,
|
||||
status: result?.status ?? 0,
|
||||
statusText: result?.statusText,
|
||||
loading: false,
|
||||
});
|
||||
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -96,15 +159,10 @@ function normalizeRefreshInterval(value: number | undefined): number | false {
|
||||
return value && Number.isFinite(value) && value > 0 ? Math.max(1_000, value * 1_000) : false;
|
||||
}
|
||||
|
||||
function renderContent(props: SubFetchProps, data: unknown, result: SubFetchMetadata | undefined) {
|
||||
if (props.render) {
|
||||
return props.render(data, {
|
||||
ok: result?.ok ?? false,
|
||||
status: result?.status ?? 0,
|
||||
statusText: result?.statusText,
|
||||
});
|
||||
}
|
||||
function renderContent(props: SubFetchProps, data: unknown, metadata: SubFetchMetadata) {
|
||||
if (typeof props.children === "function") return props.children(data, metadata);
|
||||
if (props.children) return props.children;
|
||||
if (props.path === undefined && props.as === undefined) return null;
|
||||
const value = getByPath(data, props.path);
|
||||
if (props.as === "text") return <Text size="sm">{formatDisplayValue(value)}</Text>;
|
||||
return (
|
||||
|
||||
@@ -10,13 +10,22 @@ export interface CustomWidgetRequestResult {
|
||||
data: unknown;
|
||||
error?: string;
|
||||
simulated?: boolean;
|
||||
invalidates?: string[];
|
||||
}
|
||||
|
||||
export interface CustomJsxRequestCapability {
|
||||
id: string;
|
||||
kind: "query" | "action";
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
trigger: "load" | "manual";
|
||||
minimumBoardPermission: "view" | "modify" | "full";
|
||||
confirmation?: {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
};
|
||||
invalidates?: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeRequestInput {
|
||||
@@ -46,12 +55,12 @@ export interface CustomWidgetRuntimePort {
|
||||
query(input: RuntimeRequestInput, signal?: AbortSignal): Promise<CustomWidgetRequestResult>;
|
||||
executeAction(input: RuntimeActionInput): Promise<CustomWidgetRequestResult>;
|
||||
invalidate(input: RuntimeInvalidationInput): Promise<void>;
|
||||
confirm(input: { title: string; message: ReactNode }): Promise<boolean>;
|
||||
confirm(input: { title: string; message: ReactNode; confirmLabel?: string; destructive?: boolean }): Promise<boolean>;
|
||||
notify(notification: RuntimeNotification): void;
|
||||
}
|
||||
|
||||
export interface CustomWidgetRuntimeMessages {
|
||||
migrationRequired: string;
|
||||
requestIdRequired: string;
|
||||
unsavedPreview: string;
|
||||
invalidParams: string;
|
||||
loadRequest: string;
|
||||
@@ -72,8 +81,21 @@ export interface CustomWidgetRuntimeValue {
|
||||
definitionId?: string;
|
||||
previewSessionId?: string;
|
||||
previewLiveActions?: boolean;
|
||||
queriesDisabled?: boolean;
|
||||
isEditMode: boolean;
|
||||
requestCapabilities: readonly CustomJsxRequestCapability[];
|
||||
port: CustomWidgetRuntimePort;
|
||||
messages: CustomWidgetRuntimeMessages;
|
||||
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
|
||||
}
|
||||
|
||||
export interface CustomWidgetPublishedQueryState {
|
||||
data: unknown;
|
||||
status: {
|
||||
loading: boolean;
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
statusText?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user