UNPKG

chat

Version:

Unified chat abstraction for Slack, Teams, Google Chat, and Discord

305 lines (243 loc) 10.6 kB
--- title: Modals description: Collect structured user input through modal dialogs with text fields, dropdowns, and validation. type: guide prerequisites: - /docs/actions --- Modals open form dialogs in response to button clicks or [slash commands](/docs/slash-commands). They support text inputs, dropdowns, radio buttons, and server-side validation. Currently supported on Slack and Teams. ## Open a modal Modals are opened from [action handlers](/docs/actions) or [slash command handlers](/docs/slash-commands) using `event.openModal()`: ```tsx title="lib/bot.tsx" lineNumbers import { Modal, TextInput, Select, SelectOption } from "chat"; bot.onAction("feedback", async (event) => { await event.openModal( <Modal callbackId="feedback_form" title="Send Feedback" submitLabel="Send" closeLabel="Cancel" notifyOnClose > <TextInput id="message" label="Your Feedback" placeholder="Tell us what you think..." multiline /> <Select id="category" label="Category" placeholder="Select a category"> <SelectOption label="Bug Report" value="bug" /> <SelectOption label="Feature Request" value="feature" /> <SelectOption label="General" value="general" /> </Select> <TextInput id="email" label="Email (optional)" placeholder="your@email.com" optional /> </Modal> ); }); ``` ## Components ### Modal The top-level container for the form. | Prop | Type | Description | |------|------|-------------| | `callbackId` | `string` | Identifier for matching submit/close handlers | | `title` | `string` | Modal title | | `submitLabel` | `string` (optional) | Submit button text (defaults to "Submit") | | `closeLabel` | `string` (optional) | Cancel button text (defaults to "Cancel") | | `notifyOnClose` | `boolean` (optional) | Fire `onModalClose` when user cancels | | `callbackUrl` | `string` (optional) | URL to POST form values to on submit | | `privateMetadata` | `string` (optional) | Custom context passed through to handlers | ### TextInput A text field for user input. | Prop | Type | Description | |------|------|-------------| | `id` | `string` | Field identifier (key in `event.values`) | | `label` | `string` | Field label | | `placeholder` | `string` (optional) | Placeholder text | | `initialValue` | `string` (optional) | Pre-filled value | | `multiline` | `boolean` (optional) | Render as textarea | | `optional` | `boolean` (optional) | Allow empty submission | | `maxLength` | `number` (optional) | Maximum character count | ### Select A dropdown for selecting a single option. | Prop | Type | Description | |------|------|-------------| | `id` | `string` | Field identifier | | `label` | `string` | Field label | | `placeholder` | `string` (optional) | Placeholder text | | `initialOption` | `string` (optional) | Pre-selected value | | `optional` | `boolean` (optional) | Allow empty submission | ### ExternalSelect A dropdown that loads its options dynamically from a handler as the user types. Useful for large or remote-backed option sets (people, tickets, records) where a static `<Select>` would be impractical. Slack-only. | Prop | Type | Description | |------|------|-------------| | `id` | `string` | Field identifier (key in `event.values`) | | `label` | `string` | Field label | | `placeholder` | `string` (optional) | Placeholder text | | `minQueryLength` | `number` (optional) | Minimum characters before the loader fires (Slack default: 3) | | `initialOption` | `{ label, value }` (optional) | Pre-selected option when the modal opens (must match an option returned by the loader). For static `<Select>`, `initialOption` is just the value string — for `<ExternalSelect>` it's the full `{ label, value }` object since the loader hasn't run yet. | | `optional` | `boolean` (optional) | Allow empty submission | Register the loader with `onOptionsLoad`: ```tsx title="lib/bot.tsx" lineNumbers import { ExternalSelect, Modal } from "chat"; bot.onAction("assign", async (event) => { await event.openModal( <Modal callbackId="assign_form" title="Assign to…"> <ExternalSelect id="assignee" label="Assignee" placeholder="Search people" minQueryLength={1} /> </Modal> ); }); bot.onOptionsLoad("assignee", async (event) => { const people = await peopleService.search(event.query); return people.map((p) => ({ label: p.fullName, value: p.id })); }); bot.onModalSubmit("assign_form", async (event) => { const assigneeId = event.values.assignee; // … }); ``` The selected value arrives in `event.values` on submit just like a static `<Select>`. #### Grouped options Return an array of groups instead of a flat options array to render headers between sections (e.g. "Recent" / "All"): ```tsx bot.onOptionsLoad("assignee", async (event) => { const [recent, all] = await Promise.all([ peopleService.recent(event.user.userId), peopleService.search(event.query), ]); return [ { label: "Recent", options: recent.map((p) => ({ label: p.fullName, value: p.id })) }, { label: "All", options: all.map((p) => ({ label: p.fullName, value: p.id })) }, ]; }); ``` Slack limits: max 100 groups, max 100 options per group, group label max 75 characters. <Callout type="warn"> Slack requires a response within 3 seconds for options requests. The adapter caps the loader at ~2.5s and returns an empty result on timeout — keep your loader fast (cache, prefetch, or narrow the query server-side). </Callout> <Callout type="info"> **Slack setup:** `ExternalSelect` uses Slack's `block_suggestion` payload, which is dispatched to the **Options Load URL**. In your [Slack app settings](https://api.slack.com/apps) go to **Interactivity & Shortcuts** → **Select Menus** and set the **Options Load URL** to the same endpoint as your Interactivity Request URL (e.g. `https://your-domain.com/api/webhooks/slack`). Without this, typing into an external select will silently return no results. </Callout> ### RadioSelect A radio button group for mutually exclusive options. | Prop | Type | Description | |------|------|-------------| | `id` | `string` | Field identifier | | `label` | `string` | Field label | | `initialOption` | `string` (optional) | Pre-selected value | | `optional` | `boolean` (optional) | Allow empty submission | ### SelectOption An option for `Select` or `RadioSelect`. | Prop | Type | Description | |------|------|-------------| | `label` | `string` | Display text | | `value` | `string` | Value passed to handler | | `description` | `string` (optional) | Help text below the label | ## Handle submissions Register a handler with `onModalSubmit` using the same `callbackId`: ```typescript title="lib/bot.ts" lineNumbers bot.onModalSubmit("feedback_form", async (event) => { const { message, category, email } = event.values; // Validate input — return errors to show in the modal if (!message || message.length < 5) { return { action: "errors", errors: { message: "Feedback must be at least 5 characters" }, }; } // Post confirmation to the original thread if (event.relatedThread) { await event.relatedThread.post(`Feedback received! Category: ${category}`); } // Update the message that triggered the modal if (event.relatedMessage) { await event.relatedMessage.edit("Feedback submitted!"); } // Return nothing (or { action: "close" }) to close the modal }); ``` ### Response types | Response | Description | |----------|-------------| | `undefined` or `{ action: "close" }` | Close the current view (goes back one level in the stack) | | `{ action: "clear" }` | Close all views and dismiss the modal entirely | | `{ action: "errors", errors: { fieldId: "message" } }` | Show validation errors on specific fields | | `{ action: "update", modal: ModalElement }` | Replace the modal content | | `{ action: "push", modal: ModalElement }` | Push a new modal view onto the stack | ### ModalSubmitEvent | Property | Type | Description | |----------|------|-------------| | `callbackId` | `string` | Modal identifier | | `viewId` | `string` | Platform view ID | | `values` | `Record<string, string>` | Form field values keyed by input `id` | | `user` | `Author` | The user who submitted | | `privateMetadata` | `string` (optional) | Custom context from the Modal component | | `relatedThread` | `Thread` (optional) | Thread where the modal was triggered | | `relatedMessage` | `SentMessage` (optional) | Message with the button that opened the modal | | `relatedChannel` | `Channel` (optional) | Channel where the modal was triggered (from [slash commands](/docs/slash-commands)) | | `adapter` | `Adapter` | The platform adapter | | `raw` | `unknown` | Platform-specific payload | ## Handle cancellation Optionally handle when users cancel a modal. Requires `notifyOnClose` on the `Modal` component: ```typescript title="lib/bot.ts" lineNumbers bot.onModalClose("feedback_form", async (event) => { console.log(`${event.user.userName} cancelled the feedback form`); if (event.relatedThread) { await event.relatedThread.post("No worries, let us know if you change your mind!"); } }); ``` ## Callback URLs Like buttons, modals accept a `callbackUrl`. When the modal is submitted, the form values are POSTed to the URL: ```tsx title="lib/bot.tsx" lineNumbers await event.openModal( <Modal callbackUrl={webhook.url} callbackId="intake" title="Request Access" submitLabel="Submit"> <TextInput id="reason" label="Reason" multiline /> </Modal> ); ``` The POST body for modal submissions: ```json { "type": "modal_submit", "callbackId": "intake", "values": { "reason": "Need access to production logs" }, "user": { "id": "U123", "name": "alice" } } ``` ## Pass context with privateMetadata Use `privateMetadata` to carry context from the button click through to the submit handler: ```tsx title="lib/bot.tsx" lineNumbers bot.onAction("report", async (event) => { await event.openModal( <Modal callbackId="report_form" title="Report Bug" submitLabel="Submit" privateMetadata={JSON.stringify({ reportType: event.value, threadId: event.threadId, })} > <TextInput id="title" label="Bug Title" /> <TextInput id="steps" label="Steps to Reproduce" multiline /> </Modal> ); }); bot.onModalSubmit("report_form", async (event) => { const metadata = event.privateMetadata ? JSON.parse(event.privateMetadata) : {}; console.log(metadata.reportType); // "bug" }); ```