Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions packages/chat/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import {
Button,
Card,
CardLink,
cardChildToFallbackText,
cardToFallbackText,
Divider,
Field,
Fields,
getChildrenArray,
Image,
isCardElement,
LinkButton,
Expand Down Expand Up @@ -339,4 +342,145 @@ describe("Select and RadioSelect Builder Validation", () => {
});
});

describe("getChildrenArray", () => {
it("returns array when children is already an array", () => {
const node = { children: [Text("a"), Text("b")] };
expect(getChildrenArray(node)).toEqual([
{ type: "text", content: "a", style: undefined },
{ type: "text", content: "b", style: undefined },
]);
});

it("wraps single child in array when children is not an array", () => {
const node = { children: Text("only") };
expect(getChildrenArray(node)).toHaveLength(1);
expect(getChildrenArray(node)[0]).toEqual({
type: "text",
content: "only",
style: undefined,
});
});

it("returns empty array when children is undefined", () => {
expect(getChildrenArray({})).toEqual([]);
expect(getChildrenArray({ children: undefined })).toEqual([]);
});

it("returns empty array when children is null", () => {
expect(getChildrenArray({ children: null })).toEqual([]);
});
});

describe("Fallback text with non-array children", () => {
it("cardToFallbackText handles card with children undefined", () => {
const card = {
type: "card" as const,
title: "Test",
subtitle: "Sub",
children: undefined,
};
expect(() => cardToFallbackText(card)).not.toThrow();
expect(cardToFallbackText(card)).toContain("**Test**");
expect(cardToFallbackText(card)).toContain("Sub");
});

it("cardToFallbackText handles card with children null", () => {
const card = {
type: "card" as const,
title: "Test",
children: null,
};
expect(() => cardToFallbackText(card)).not.toThrow();
expect(cardToFallbackText(card)).toBe("**Test**");
});

it("cardToFallbackText handles card with single child (non-array)", () => {
const card = {
type: "card" as const,
title: "Test",
children: Text("Only child"),
};
expect(() => cardToFallbackText(card)).not.toThrow();
expect(cardToFallbackText(card)).toContain("**Test**");
expect(cardToFallbackText(card)).toContain("Only child");
});

it("cardChildToFallbackText handles section with children undefined", () => {
const section = {
type: "section" as const,
children: undefined,
};
expect(() => cardChildToFallbackText(section)).not.toThrow();
expect(cardChildToFallbackText(section)).toBe("");
});

it("cardChildToFallbackText handles section with children null", () => {
const section = {
type: "section" as const,
children: null,
};
expect(() => cardChildToFallbackText(section)).not.toThrow();
expect(cardChildToFallbackText(section)).toBe("");
});

it("cardChildToFallbackText handles section with single child (non-array)", () => {
const section = {
type: "section" as const,
children: Text("Single"),
};
expect(() => cardChildToFallbackText(section)).not.toThrow();
expect(cardChildToFallbackText(section)).toBe("Single");
});

it("cardChildToFallbackText handles fields with children undefined", () => {
const fields = {
type: "fields" as const,
children: undefined,
};
expect(() => cardChildToFallbackText(fields)).not.toThrow();
expect(cardChildToFallbackText(fields)).toBe("");
});

it("cardChildToFallbackText handles fields with children null", () => {
const fields = {
type: "fields" as const,
children: null,
};
expect(() => cardChildToFallbackText(fields)).not.toThrow();
expect(cardChildToFallbackText(fields)).toBe("");
});

it("cardChildToFallbackText handles fields with single field (non-array)", () => {
const fields = {
type: "fields" as const,
children: Field({ label: "Name", value: "Alice" }),
};
expect(() => cardChildToFallbackText(fields)).not.toThrow();
expect(cardChildToFallbackText(fields)).toBe("Name: Alice");
});

it("cardToFallbackText with section and fields with non-array children (serialization-style)", () => {
const card = {
type: "card" as const,
title: "Review",
subtitle: "Please confirm",
children: [
{
type: "section" as const,
children: Text("Approve or reject below."),
},
{
type: "fields" as const,
children: Field({ label: "Status", value: "Pending" }),
},
],
};
expect(() => cardToFallbackText(card)).not.toThrow();
const fallback = cardToFallbackText(card);
expect(fallback).toContain("**Review**");
expect(fallback).toContain("Approve or reject below.");
expect(fallback).toContain("Status: Pending");
});
});

// JSX tests moved to jsx-react.test.tsx and jsx-runtime.test.tsx
27 changes: 22 additions & 5 deletions packages/chat/src/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,18 @@ function extractTextContent(children: unknown): string {
// Fallback Text Generation
// ============================================================================

/** Ensure children is always an array (handles JSX/serialization where it may be single or undefined). */
export function getChildrenArray(node: { children?: unknown }): unknown[] {
const c = node.children;
if (Array.isArray(c)) {
return c;
}
if (c != null) {
return [c];
}
return [];
}

/**
* Generate plain text fallback from a CardElement.
* Used for platforms/clients that can't render rich cards,
Expand All @@ -761,8 +773,8 @@ export function cardToFallbackText(card: CardElement): string {
parts.push(card.subtitle);
}

for (const child of card.children) {
const text = cardChildToFallbackText(child);
for (const child of getChildrenArray(card)) {
const text = cardChildToFallbackText(child as CardChild);
if (text) {
parts.push(text);
}
Expand All @@ -782,16 +794,21 @@ export function cardChildToFallbackText(child: CardChild): string | null {
case "link":
return `${child.label} (${child.url})`;
case "fields":
return child.children.map((f) => `${f.label}: ${f.value}`).join("\n");
return getChildrenArray(child)
.map((f: unknown) => {
const { label, value } = f as FieldElement;
return `${label}: ${value}`;
})
.join("\n");
case "actions":
// Actions are interactive-only — exclude from fallback text.
// See: https://docs.slack.dev/reference/methods/chat.postMessage
return null;
case "table":
return tableElementToAscii(child.headers, child.rows);
case "section":
return child.children
.map((c) => cardChildToFallbackText(c))
return getChildrenArray(child)
.map((c: unknown) => cardChildToFallbackText(c as CardChild))
.filter(Boolean)
.join("\n");
default:
Expand Down
21 changes: 15 additions & 6 deletions packages/chat/src/jsx-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,24 @@ export interface SectionComponent {
(props: ContainerProps): ChatElement;
}

/** Action child type for Actions() - buttons and selects only. */
type ActionsChild =
| ButtonElement
| LinkButtonElement
| SelectElement
| RadioSelectElement;

export interface ActionsComponent {
/**
* Array form (preferred): Actions([Button(...), Button(...)]).
* Accepts CardChild[] so that Actions([...]) nested in Card({ children: [...] })
* type-checks when the array is contextually typed as CardChild[].
* Runtime still expects only button/select elements.
*/
(
children: (
| ButtonElement
| LinkButtonElement
| SelectElement
| RadioSelectElement
)[]
children: readonly CardChild[] | readonly ActionsChild[] | ActionsChild[]
): ActionsElement;
/** Props form for JSX: <Actions><Button ... /></Actions> */
(props: ContainerProps): ChatElement;
}

Expand Down
22 changes: 15 additions & 7 deletions packages/chat/src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { unified } from "unified";
import type { CardChild, CardElement } from "./cards";
import {
type CardChild,
type CardElement,
type FieldElement,
getChildrenArray,
} from "./cards";
import type { AdapterPostableMessage } from "./types";

// Alias for use within this file
Expand Down Expand Up @@ -564,8 +569,8 @@ export abstract class BaseFormatConverter implements FormatConverter {
parts.push(card.subtitle);
}

for (const child of card.children) {
const text = this.cardChildToFallbackText(child);
for (const child of getChildrenArray(card)) {
const text = this.cardChildToFallbackText(child as CardChild);
if (text) {
parts.push(text);
}
Expand All @@ -582,8 +587,11 @@ export abstract class BaseFormatConverter implements FormatConverter {
case "text":
return child.content;
case "fields":
return child.children
.map((f) => `**${f.label}**: ${f.value}`)
return getChildrenArray(child)
.map((f: unknown) => {
const { label, value } = f as FieldElement;
return `**${label}**: ${value}`;
})
.join("\n");
case "actions":
// Actions are interactive-only — exclude from fallback text.
Expand All @@ -592,8 +600,8 @@ export abstract class BaseFormatConverter implements FormatConverter {
case "table":
return tableElementToAscii(child.headers, child.rows);
case "section":
return child.children
.map((c) => this.cardChildToFallbackText(c))
return getChildrenArray(child)
.map((c: unknown) => this.cardChildToFallbackText(c as CardChild))
.filter(Boolean)
.join("\n");
default:
Expand Down