Files
Nicolás Montone c7d51cb8f4 add support for privatemetadata in slack adapter (#40)
* add private metadata in chat adapter

* add test on jsx runtime

* changeset
2026-02-14 17:17:03 -08:00

299 lines
7.4 KiB
TypeScript

/**
* Modal elements for form dialogs.
*/
import type { FieldsElement, TextElement } from "./cards";
// ============================================================================
// Modal Element Types
// ============================================================================
export const VALID_MODAL_CHILD_TYPES = [
"text_input",
"select",
"text",
"fields",
] as const;
export type ModalChild =
| TextInputElement
| SelectElement
| TextElement
| FieldsElement;
export interface ModalElement {
type: "modal";
callbackId: string;
title: string;
submitLabel?: string;
closeLabel?: string;
notifyOnClose?: boolean;
/** Arbitrary string passed through the modal lifecycle (e.g., JSON context). */
privateMetadata?: string;
children: ModalChild[];
}
export interface TextInputElement {
type: "text_input";
id: string;
label: string;
placeholder?: string;
initialValue?: string;
multiline?: boolean;
optional?: boolean;
maxLength?: number;
}
export interface SelectElement {
type: "select";
id: string;
label: string;
placeholder?: string;
options: SelectOptionElement[];
initialOption?: string;
optional?: boolean;
}
export interface SelectOptionElement {
label: string;
value: string;
}
export function isModalElement(value: unknown): value is ModalElement {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
(value as ModalElement).type === "modal"
);
}
export function filterModalChildren(children: unknown[]): ModalChild[] {
const validChildren = children.filter(
(c): c is ModalChild =>
typeof c === "object" &&
c !== null &&
"type" in c &&
VALID_MODAL_CHILD_TYPES.includes(
(c as { type: string })
.type as (typeof VALID_MODAL_CHILD_TYPES)[number],
),
);
if (validChildren.length < children.length) {
console.warn(
"[chat] Modal contains unsupported child elements that were ignored",
);
}
return validChildren;
}
// ============================================================================
// Builder Functions
// ============================================================================
export interface ModalOptions {
callbackId: string;
title: string;
submitLabel?: string;
closeLabel?: string;
notifyOnClose?: boolean;
/** Arbitrary string passed through the modal lifecycle (e.g., JSON context). */
privateMetadata?: string;
children?: ModalChild[];
}
export function Modal(options: ModalOptions): ModalElement {
return {
type: "modal",
callbackId: options.callbackId,
title: options.title,
submitLabel: options.submitLabel,
closeLabel: options.closeLabel,
notifyOnClose: options.notifyOnClose,
privateMetadata: options.privateMetadata,
children: options.children ?? [],
};
}
export interface TextInputOptions {
id: string;
label: string;
placeholder?: string;
initialValue?: string;
multiline?: boolean;
optional?: boolean;
maxLength?: number;
}
export function TextInput(options: TextInputOptions): TextInputElement {
return {
type: "text_input",
id: options.id,
label: options.label,
placeholder: options.placeholder,
initialValue: options.initialValue,
multiline: options.multiline,
optional: options.optional,
maxLength: options.maxLength,
};
}
export interface SelectOptions {
id: string;
label: string;
placeholder?: string;
options: SelectOptionElement[];
initialOption?: string;
optional?: boolean;
}
export function Select(options: SelectOptions): SelectElement {
return {
type: "select",
id: options.id,
label: options.label,
placeholder: options.placeholder,
options: options.options,
initialOption: options.initialOption,
optional: options.optional,
};
}
export function SelectOption(options: {
label: string;
value: string;
}): SelectOptionElement {
return {
label: options.label,
value: options.value,
};
}
// ============================================================================
// JSX Support
// ============================================================================
interface ReactElement {
$$typeof: symbol;
type: unknown;
props: Record<string, unknown>;
}
function isReactElement(value: unknown): value is ReactElement {
if (typeof value !== "object" || value === null) {
return false;
}
const maybeElement = value as { $$typeof?: unknown };
if (typeof maybeElement.$$typeof !== "symbol") {
return false;
}
const symbolStr = maybeElement.$$typeof.toString();
return (
symbolStr.includes("react.element") ||
symbolStr.includes("react.transitional.element")
);
}
type AnyModalElement = ModalElement | ModalChild | SelectOptionElement;
const modalComponentMap = new Map<unknown, string>([
[Modal, "Modal"],
[TextInput, "TextInput"],
[Select, "Select"],
[SelectOption, "SelectOption"],
]);
export function fromReactModalElement(
element: unknown,
): AnyModalElement | null {
if (!isReactElement(element)) {
if (isModalElement(element)) {
return element;
}
if (typeof element === "object" && element !== null && "type" in element) {
return element as ModalChild;
}
return null;
}
const { type, props } = element;
const componentName = modalComponentMap.get(type);
if (!componentName) {
if (props.children) {
return convertModalChildren(props.children)[0] ?? null;
}
return null;
}
const convertedChildren = props.children
? convertModalChildren(props.children)
: [];
switch (componentName) {
case "Modal":
return Modal({
callbackId: props.callbackId as string,
title: props.title as string,
submitLabel: props.submitLabel as string | undefined,
closeLabel: props.closeLabel as string | undefined,
notifyOnClose: props.notifyOnClose as boolean | undefined,
privateMetadata: props.privateMetadata as string | undefined,
children: filterModalChildren(convertedChildren),
});
case "TextInput":
return TextInput({
id: props.id as string,
label: props.label as string,
placeholder: props.placeholder as string | undefined,
initialValue: props.initialValue as string | undefined,
multiline: props.multiline as boolean | undefined,
optional: props.optional as boolean | undefined,
maxLength: props.maxLength as number | undefined,
});
case "Select":
return Select({
id: props.id as string,
label: props.label as string,
placeholder: props.placeholder as string | undefined,
options: convertedChildren.filter(
(c): c is SelectOptionElement =>
c !== null && "label" in c && "value" in c && !("type" in c),
),
initialOption: props.initialOption as string | undefined,
optional: props.optional as boolean | undefined,
});
case "SelectOption":
return SelectOption({
label: props.label as string,
value: props.value as string,
});
default:
return null;
}
}
function convertModalChildren(children: unknown): AnyModalElement[] {
if (children == null) {
return [];
}
if (Array.isArray(children)) {
return children.flatMap(convertModalChildren);
}
const converted = fromReactModalElement(children);
if (converted) {
if (isModalElement(converted)) {
return converted.children;
}
return [converted];
}
return [];
}