feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

+57 -32
View File
@@ -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 (
+24 -2
View File
@@ -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;
};
}