feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,261 @@
"use client";
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";
import { useMutation } from "@tanstack/react-query";
import { useCustomWidgetRuntime } from "./context";
import { normalizeParams } from "./data";
import { RequestIdRequiredAlert } from "./status";
import type { CustomJsxRuntimeParams, CustomWidgetRequestResult } from "./types";
const ICON_MAP: Record<string, ComponentType<{ size?: number | string }>> = {
play: IconPlayerPlay,
check: IconCheck,
refresh: IconRefresh,
power: IconPower,
trash: IconTrash,
};
function parseBool(value: unknown, fallback = false) {
if (typeof value === "boolean") return value;
if (value === "true") return true;
if (value === "false") return false;
return fallback;
}
function useActionExecutor() {
const runtime = useCustomWidgetRuntime();
const mutation = useMutation({
mutationFn: async ({
requestId,
params,
confirmed,
}: {
requestId: string;
params: CustomJsxRuntimeParams;
confirmed: boolean;
}): Promise<CustomWidgetRequestResult> => {
if (!runtime.itemId && !runtime.previewSessionId) {
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.widgetItemUnavailable });
}
if (runtime.isEditMode) {
return Promise.resolve({ ok: false, status: 0, data: null, error: runtime.messages.actionsDisabledEditMode });
}
runtime.setQueryState?.(requestId, { data: null, status: { loading: true } });
let result: CustomWidgetRequestResult;
try {
result = await runtime.port.executeAction({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
requestId,
params,
confirmed,
});
} catch (error) {
runtime.setQueryState?.(requestId, {
data: null,
status: { loading: false, ok: false, error: runtime.messages.requestFailed },
});
throw error;
}
// Action responses are local to this widget instance, just like published query data.
runtime.setQueryState?.(requestId, {
data: result.data,
status: {
loading: false,
ok: result.ok,
status: result.status,
statusText: result.statusText,
error: result.error,
},
});
return result;
},
});
return { runtime, mutation };
}
export interface ActionButtonProps {
requestId?: string;
params?: CustomJsxRuntimeParams;
label?: string;
children?: ReactNode;
color?: string;
variant?: string;
size?: string;
confirmMessage?: string;
successMessage?: string;
errorMessage?: string;
icon?: string;
disabled?: boolean | 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 capability = runtime.requestCapabilities.find(
(candidate) => candidate.id === props.requestId && candidate.kind === "action",
);
const run = async (confirmed: boolean) => {
if (!props.requestId || !params) return;
try {
const result = await mutation.mutateAsync({ requestId: props.requestId, params, confirmed });
const success = result.ok;
runtime.port.notify({
kind: success ? "success" : "error",
title,
message: success
? (props.successMessage ??
(result.simulated ? runtime.messages.actionSimulated : runtime.messages.actionCompleted))
: (props.errorMessage ?? result.error ?? `${runtime.messages.requestFailed} (${result.status})`),
});
if (success)
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: result.invalidates ?? capability?.invalidates ?? [],
});
} catch {
runtime.port.notify({
kind: "error",
title,
message: props.errorMessage ?? runtime.messages.requestFailed,
});
}
};
const click = async () => {
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 <RequestIdRequiredAlert />;
return (
<Button
color={props.color ?? "blue"}
variant={(props.variant ?? "filled") as never}
size={(props.size ?? "sm") as never}
onClick={() => void click()}
loading={mutation.isPending}
leftSection={Icon ? <Icon size={16} /> : undefined}
disabled={
parseBool(props.disabled) || runtime.isEditMode || (!runtime.itemId && !runtime.previewSessionId) || !params
}
fullWidth={parseBool(props.fullWidth)}
>
{props.children ?? props.label ?? props.requestId}
</Button>
);
}
export interface ToggleSwitchProps {
requestId?: string;
enabledParams?: CustomJsxRuntimeParams;
disabledParams?: CustomJsxRuntimeParams;
initialValue?: boolean | string;
label?: string;
color?: string;
size?: string;
errorMessage?: string;
disabled?: boolean | string;
}
export function ToggleSwitch(props: ToggleSwitchProps) {
const { runtime, mutation } = useActionExecutor();
const initial = parseBool(props.initialValue);
const [checked, setChecked] = useState(initial);
const locked = useRef(false);
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 ? 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: confirmMessage !== undefined,
});
if (!result.ok) {
setChecked(previous);
runtime.port.notify({
kind: "error",
title: props.label ?? runtime.messages.toggle,
message: props.errorMessage ?? result.error ?? runtime.messages.requestFailed,
});
} else {
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets: result.invalidates ?? capability?.invalidates ?? [],
});
}
} catch {
setChecked(previous);
runtime.port.notify({
kind: "error",
title: props.label ?? runtime.messages.toggle,
message: props.errorMessage ?? runtime.messages.requestFailed,
});
} finally {
locked.current = false;
}
};
if (!props.requestId) return <RequestIdRequiredAlert />;
return (
<Switch
label={props.label}
color={props.color ?? "blue"}
size={(props.size ?? "sm") as never}
checked={checked}
onChange={(event) => void change(event.currentTarget.checked)}
disabled={
parseBool(props.disabled) ||
mutation.isPending ||
runtime.isEditMode ||
(!runtime.itemId && !runtime.previewSessionId) ||
!enabledParams ||
!disabledParams
}
/>
);
}
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext } from "react";
import type { CustomWidgetRuntimeValue } from "./types";
const RuntimeContext = createContext<CustomWidgetRuntimeValue | null>(null);
export function CustomWidgetRuntimeProvider({
children,
...value
}: CustomWidgetRuntimeValue & { children: ReactNode }) {
return <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>;
}
export function useCustomWidgetRuntime(): CustomWidgetRuntimeValue {
const value = useContext(RuntimeContext);
if (!value) throw new Error("Custom Widget runtime components require CustomWidgetRuntimeProvider");
return value;
}
export function useWidgetDefinitionId(): string {
return useCustomWidgetRuntime().definitionId ?? "";
}
@@ -0,0 +1,260 @@
import type { ComponentType, ErrorInfo, ReactNode } from "react";
import { Component, useCallback, useEffect, useId, useMemo, useRef, 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 { sameInputRecord, sameStringArray, sameTypeRecord } from "./input-state-utils";
export { CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./request-capabilities";
const EMPTY_RECORD: Record<string, never> = {};
export interface CustomJsxRendererMessages {
noTemplate: string;
templateWarnings(count: number): string;
bindingTypeConflict(name: string, firstType: WidgetInputType, secondType: WidgetInputType): string;
}
export interface CustomJsxRendererProps {
template: string;
data: unknown;
status?: Record<string, unknown>;
options?: Record<string, unknown>;
components: Readonly<Record<string, ComponentType<never>>>;
createBindings(data: unknown): Readonly<Record<string, unknown>>;
messages: CustomJsxRendererMessages;
}
class RendererErrorBoundary extends Component<
{ children: ReactNode; resetKey: string; onError(error: Error): void },
{ error: Error | null; resetKey: string }
> {
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 };
}
public componentDidCatch(error: Error, _info: ErrorInfo) {
this.props.onError(error);
}
public render() {
return this.state.error ? <ErrorAlert error={this.state.error} /> : this.props.children;
}
}
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(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 {...props} />;
}
interface InputRegistration {
name: string;
type: WidgetInputType;
initialValue: WidgetInputValue;
}
interface InputState {
values: Record<string, WidgetInputValue>;
types: Record<string, WidgetInputType>;
}
function CustomJsxRendererSession({
template,
data,
status = EMPTY_RECORD,
options = EMPTY_RECORD,
components,
createBindings,
messages,
}: CustomJsxRendererProps) {
const inputScopeId = useId();
const [parseErrors, setParseErrors] = useState<string[]>([]);
const [bindingErrors, setBindingErrors] = useState<string[]>([]);
const [inputState, setInputState] = useState<InputState>({ values: {}, types: {} });
const { values: inputs, types: inputTypes } = inputState;
const registrations = useRef(new Map<symbol, InputRegistration>());
const registrationVersion = useRef(0);
const mounted = useRef(true);
const bindingTypeConflict = messages.bindingTypeConflict;
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const reconcileInputs = useCallback(() => {
if (!mounted.current) return;
const active = new Map<string, InputRegistration>();
const conflicts: string[] = [];
for (const registration of registrations.current.values()) {
const existing = active.get(registration.name);
if (!existing) {
active.set(registration.name, registration);
continue;
}
if (existing.type === registration.type) continue;
const message = bindingTypeConflict(registration.name, existing.type, registration.type);
if (!conflicts.includes(message) && conflicts.length < 5) conflicts.push(message);
}
setBindingErrors((current) => (sameStringArray(current, conflicts) ? current : conflicts));
setInputState((current) => {
const nextTypes: Record<string, WidgetInputType> = {};
for (const [name, registration] of active) nextTypes[name] = registration.type;
const nextValues: Record<string, WidgetInputValue> = {};
for (const [name, registration] of active) {
if (current.types[name] === registration.type && Object.hasOwn(current.values, name)) {
const previousValue = current.values[name];
if (previousValue !== undefined) {
nextValues[name] = previousValue;
continue;
}
}
nextValues[name] = registration.initialValue;
}
if (sameTypeRecord(current.types, nextTypes) && sameInputRecord(current.values, nextValues)) return current;
return { values: nextValues, types: nextTypes };
});
}, [bindingTypeConflict]);
const registerInput = useCallback(
(name: string, type: WidgetInputType, initialValue: WidgetInputValue) => {
const id = Symbol(name);
registrations.current.set(id, { name, type, initialValue });
registrationVersion.current += 1;
reconcileInputs();
return () => {
registrations.current.delete(id);
registrationVersion.current += 1;
const version = registrationVersion.current;
queueMicrotask(() => {
if (registrationVersion.current === version) reconcileInputs();
});
};
},
[reconcileInputs],
);
const setInputValue = useCallback((name: string, type: WidgetInputType, value: WidgetInputValue) => {
setInputState((current) => {
const existing = current.types[name];
if (existing && existing !== type) return current;
if (existing === type && Object.is(current.values[name], value)) return current;
return {
values: { ...current.values, [name]: value },
types: existing === type ? current.types : { ...current.types, [name]: type },
};
});
}, []);
const resetInput = useCallback((name: string, type: WidgetInputType) => {
let activeRegistration: InputRegistration | undefined;
for (const registration of registrations.current.values()) {
if (registration.name !== name) continue;
activeRegistration = registration;
break;
}
if (!activeRegistration || activeRegistration.type !== type) return;
setInputState((current) => {
if (current.types[name] !== type || Object.is(current.values[name], activeRegistration.initialValue)) {
return current;
}
return { ...current, values: { ...current.values, [name]: activeRegistration.initialValue } };
});
}, []);
const rendered = useMemo(() => {
try {
const bindings = { ...createBindings(data), status, options, inputs };
return {
...renderSafeJsx({ template, components, bindings }),
boundaryKey: createBoundaryKey(template, bindings),
error: null,
};
} catch (error) {
return {
node: null,
warnings: [],
boundaryKey: `${template.length}:error`,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}, [components, createBindings, data, inputs, options, status, template]);
useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]);
const handleError = useCallback(
(error: Error) =>
setParseErrors((current) => {
if (current.length >= 5 || current.includes(error.message)) return current;
return [...current, error.message];
}),
[],
);
useEffect(() => {
rendered.warnings.forEach((warning) => handleError(new Error(warning)));
if (rendered.error) handleError(rendered.error);
}, [handleError, rendered]);
return (
<Stack gap={0} h="100%">
<Box h="100%" style={{ contain: "layout paint style", isolation: "isolate", overflow: "auto", minHeight: 0 }}>
{rendered.error ? (
<ErrorAlert error={rendered.error} />
) : (
<CustomJsxInputsProvider
scopeId={inputScopeId}
inputs={inputs}
inputTypes={inputTypes}
registerInput={registerInput}
setInputValue={setInputValue}
resetInput={resetInput}
>
<RendererErrorBoundary resetKey={rendered.boundaryKey} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</CustomJsxInputsProvider>
)}
</Box>
{[...parseErrors, ...bindingErrors].length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{messages.templateWarnings(parseErrors.length + bindingErrors.length)}
</Text>
{[...parseErrors, ...bindingErrors].map((message) => (
<Text key={message} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{message}
</Text>
))}
</Alert>
)}
</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}`;
}
@@ -0,0 +1,90 @@
"use client";
import { createContext, useContext } from "react";
import { Badge, Code, Image, Text, Title } from "@mantine/core";
export const SubFetchDataContext = createContext<unknown>(undefined);
const BLOCKED_KEYS = new Set(["constructor", "prototype", "__proto__"]);
export function normalizeParams(value: unknown) {
if (value === undefined) return {};
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const result: Record<string, string | number | boolean> = {};
for (const [key, param] of Object.entries(value)) {
if (BLOCKED_KEYS.has(key)) return null;
if (typeof param !== "string" && typeof param !== "number" && typeof param !== "boolean") return null;
result[key] = param;
}
return result;
}
export function getByPath(value: unknown, path?: string): unknown {
if (!path?.trim()) return value;
return path.split(".").reduce<unknown>((current, key) => {
if (current == null || typeof current !== "object" || BLOCKED_KEYS.has(key)) return undefined;
return Object.hasOwn(current, key) ? (current as Record<string, unknown>)[key] : undefined;
}, value);
}
export function formatDisplayValue(value: unknown): string {
if (value == null) return "—";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
export interface SubDataProps {
path?: string;
as?: string;
order?: number;
size?: string;
color?: string;
variant?: string;
fw?: number;
c?: string;
alt?: string;
fit?: string;
w?: string | number;
h?: string | number;
radius?: string | number;
}
export function SubData({ path, as = "Text", ...props }: SubDataProps) {
const value = getByPath(useContext(SubFetchDataContext), path);
if (value === undefined && path) return <Text c="dimmed">—</Text>;
const displayValue = as === "Code" ? JSON.stringify(value, null, 2) : formatDisplayValue(value);
if (as === "Title") {
return <Title order={(props.order as 1 | 2 | 3 | 4 | 5 | 6) ?? 3}>{displayValue}</Title>;
}
if (as === "Badge") {
return (
<Badge color={props.color} variant={props.variant as never} size={props.size as never}>
{displayValue}
</Badge>
);
}
if (as === "Code") return <Code block>{displayValue}</Code>;
if (as === "Image") {
const src = safeDisplayImageUrl(value);
if (!src) return null;
return (
<Image src={src} alt={props.alt ?? ""} fit={props.fit as never} w={props.w} h={props.h} radius={props.radius} />
);
}
return (
<Text size={props.size as never} fw={props.fw} c={props.c}>
{displayValue}
</Text>
);
}
function safeDisplayImageUrl(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
if (value.startsWith("/") && !value.startsWith("//")) return value;
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
} catch {
return undefined;
}
}
@@ -0,0 +1,12 @@
export { ActionButton, ToggleSwitch } from "./actions";
export { CustomWidgetRuntimeProvider, useCustomWidgetRuntime, useWidgetDefinitionId } from "./context";
export { SubData } from "./data";
export { RefreshButton } from "./refresh-button";
export { SubFetch } from "./sub-fetch";
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 "./types";
@@ -0,0 +1,19 @@
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
export function sameStringArray(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
export function sameTypeRecord(left: Record<string, WidgetInputType>, right: Record<string, WidgetInputType>) {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
return leftEntries.length === rightEntries.length && leftEntries.every(([name, type]) => right[name] === type);
}
export function sameInputRecord(left: Record<string, WidgetInputValue>, right: Record<string, WidgetInputValue>) {
const leftEntries = Object.entries(left);
const rightEntries = Object.entries(right);
return (
leftEntries.length === rightEntries.length && leftEntries.every(([name, value]) => Object.is(right[name], value))
);
}
@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { ActionIcon } from "@mantine/core";
import { IconRefresh } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
export interface RefreshButtonProps {
requestId?: string;
label?: string;
color?: string;
variant?: string;
size?: string;
}
export function RefreshButton({
requestId,
label,
color = "gray",
variant = "subtle",
size = "sm",
}: RefreshButtonProps) {
const runtime = useCustomWidgetRuntime();
const [loading, setLoading] = useState(false);
const refresh = async () => {
if (!runtime.itemId && !runtime.previewSessionId) return;
setLoading(true);
try {
let targets = ["parent", "*"];
if (requestId) targets = [requestId];
await runtime.port.invalidate({
itemId: runtime.itemId,
previewSessionId: runtime.previewSessionId,
targets,
});
} finally {
setLoading(false);
}
};
return (
<ActionIcon
aria-label={label ?? runtime.messages.refresh}
color={color}
variant={variant as never}
size={size as never}
onClick={() => void refresh()}
loading={loading}
disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode}
>
<IconRefresh size={16} />
</ActionIcon>
);
}
@@ -0,0 +1,60 @@
import type { CustomJsxRequestCapability } from "./types";
export const CUSTOM_JSX_METHOD_COLORS: Readonly<Record<string, string>> = {
GET: "blue",
POST: "orange",
PUT: "yellow",
DELETE: "red",
PATCH: "grape",
};
const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const kinds = new Set(["query", "action"]);
const permissions = new Set(["view", "modify", "full"]);
const parseConfirmation = (value: unknown): CustomJsxRequestCapability["confirmation"] => {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (typeof record.title !== "string" || typeof record.message !== "string") return undefined;
if (record.confirmLabel !== undefined && typeof record.confirmLabel !== "string") return undefined;
if (record.destructive !== undefined && typeof record.destructive !== "boolean") return undefined;
const confirmation: NonNullable<CustomJsxRequestCapability["confirmation"]> = {
title: record.title,
message: record.message,
};
if (typeof record.confirmLabel === "string") confirmation.confirmLabel = record.confirmLabel;
if (typeof record.destructive === "boolean") confirmation.destructive = record.destructive;
return confirmation;
};
export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
if (!Array.isArray(value)) return [];
return value.flatMap((candidate) => {
if (!candidate || typeof candidate !== "object") return [];
const record = candidate as Record<string, unknown>;
if (
typeof record.id !== "string" ||
typeof record.kind !== "string" ||
!kinds.has(record.kind) ||
typeof record.method !== "string" ||
!methods.has(record.method) ||
typeof record.minimumBoardPermission !== "string" ||
!permissions.has(record.minimumBoardPermission)
)
return [];
return [
{
id: record.id,
kind: record.kind,
method: record.method,
trigger: record.trigger === "load" ? "load" : "manual",
minimumBoardPermission: record.minimumBoardPermission,
confirmation: parseConfirmation(record.confirmation),
invalidates: Array.isArray(record.invalidates)
? record.invalidates.filter((entry): entry is string => typeof entry === "string")
: [],
} as CustomJsxRequestCapability,
];
});
}
@@ -0,0 +1,15 @@
"use client";
import { Alert, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useCustomWidgetRuntime } from "./context";
export function RequestIdRequiredAlert() {
const { messages } = useCustomWidgetRuntime();
return (
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{messages.requestIdRequired}</Text>
</Alert>
);
}
@@ -0,0 +1,207 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useMemo, useState } from "react";
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 { 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 | ((data: unknown, metadata: SubFetchMetadata) => ReactNode);
loadingLabel?: string;
errorMessage?: string;
fallback?: ReactNode;
triggerContent?: ReactNode;
triggerAriaLabel?: string;
path?: string;
as?: "json" | "text";
trigger?: "auto" | "manual";
}
// Browser timers use signed 32-bit millisecond delays. Whole-second refresh
// intervals are capped at the largest value that remains below that boundary.
export const MAX_REFRESH_INTERVAL_SECONDS = 2_147_483;
export const MAX_REFRESH_INTERVAL_MS = MAX_REFRESH_INTERVAL_SECONDS * 1_000;
export function SubFetch(props: SubFetchProps) {
const { itemId, previewSessionId, queryCacheKey, queriesDisabled, port, messages, setQueryState } =
useCustomWidgetRuntime();
const [manualRunKey, setManualRunKey] = useState<string | null>(null);
const params = useMemo(() => normalizeParams(props.params), [props.params]);
const paramsKey = useMemo(() => JSON.stringify(params), [params]);
const manualRequestKey = JSON.stringify([
itemId,
previewSessionId,
queryCacheKey,
props.requestId,
props.trigger,
paramsKey,
]);
const enabled = Boolean(
!queriesDisabled &&
(itemId || previewSessionId) &&
props.requestId &&
params &&
(props.trigger !== "manual" || manualRunKey === manualRequestKey),
);
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
const scope = itemId ? "item" : "preview";
const scopeId = itemId ?? previewSessionId;
const query = useQuery({
queryKey: ["custom-widget", scope, scopeId, props.requestId, paramsKey, queryCacheKey],
queryFn: ({ signal }) =>
port.query({ itemId, previewSessionId, requestId: props.requestId ?? "", params: params ?? {} }, signal),
enabled,
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 <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" && manualRunKey !== manualRequestKey) {
if (props.triggerContent) {
return (
<Box
component="button"
type="button"
aria-label={props.triggerAriaLabel ?? messages.loadRequest}
onClick={() => setManualRunKey(manualRequestKey)}
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={() => setManualRunKey(manualRequestKey)}>
{messages.loadRequest}
</Button>
);
}
if (!props.children && props.path === undefined && props.as === undefined) return null;
const result = query.data;
const requestError =
(query.error && !result ? messages.requestFailed : null) ??
(!result?.ok ? (result?.error ?? `${messages.requestFailed} (${result?.status ?? 0})`) : null);
if (!result && query.isPending) return props.fallback ?? <Loading label={props.loadingLabel ?? messages.loading} />;
if (requestError) {
return (
<RequestAlert message={props.errorMessage ?? requestError}>
<Button
size="compact-xs"
variant="subtle"
mt={4}
onClick={() => void query.refetch()}
loading={query.isFetching}
>
{messages.retry}
</Button>
</RequestAlert>
);
}
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>;
}
export function normalizeRefreshInterval(value: number | undefined): number | false {
if (!value || !Number.isFinite(value) || value <= 0) return false;
return Math.max(1_000, Math.min(value, MAX_REFRESH_INTERVAL_SECONDS) * 1_000);
}
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 (
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(value, null, 2)}
</Code>
);
}
function Loading({ label }: { label: string }) {
return (
<div>
<Skeleton height={8} radius="xl" mb="xs" />
<Text c="dimmed">{label}</Text>
</div>
);
}
function RequestAlert({ message, children }: { message: string; children?: ReactNode }) {
return (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{message}</Text>
{children}
</Alert>
);
}
@@ -0,0 +1,102 @@
import type { ReactNode } from "react";
export type RuntimeParam = string | number | boolean;
export type CustomJsxRuntimeParams = Record<string, RuntimeParam>;
export interface CustomWidgetRequestResult {
ok: boolean;
status: number;
statusText?: string;
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 {
itemId?: string;
previewSessionId?: string;
requestId: string;
params: CustomJsxRuntimeParams;
}
export interface RuntimeActionInput extends RuntimeRequestInput {
confirmed: boolean;
}
export interface RuntimeInvalidationInput {
itemId?: string;
previewSessionId?: string;
targets: readonly string[];
}
export interface RuntimeNotification {
kind: "success" | "error";
title: string;
message: string;
}
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; confirmLabel?: string; destructive?: boolean }): Promise<boolean>;
notify(notification: RuntimeNotification): void;
}
export interface CustomWidgetRuntimeMessages {
requestIdRequired: string;
unsavedPreview: string;
invalidParams: string;
loadRequest: string;
requestFailed: string;
loading: string;
retry: string;
widgetItemUnavailable: string;
actionsDisabledEditMode: string;
actionSimulated: string;
actionCompleted: string;
confirmDelete: string;
toggle: string;
refresh: string;
}
export interface CustomWidgetRuntimeValue {
itemId?: string;
definitionId?: string;
queryCacheKey?: 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;
};
}