From 44849679a7de9e2f7183be5dcba5f812c8bf3f36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 00:23:59 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20compose-only=20mode=20=E2=80=94=20o?= =?UTF-8?q?ptional=20send=20integration=20+=20onValidationChange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split BlockKitchenProps into a shared base plus an all-or-nothing send integration union: provide loadChannels/loadSendAsUserStatus/onSend for the built-in send flow, or omit all three and the builder renders no send button, becoming a pure editor whose host owns the send flow. Partial wiring is a type error (and a runtime console warning for JS consumers); `editing` requires the trio since chat.update is channel-bound. Add onValidationChange, reporting { valid, errorCount, errors } whenever the draft's verdict changes — the exact verdict the issues sheet shows, validated against the message surface — so a host-rendered CTA can gate on validity without re-running the validator and risking drift. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01J2JGjyg1LC9RxdjBFR8oeJ --- README.md | 71 +++++++++++++- src/components/block-kitchen.stories.tsx | 9 +- src/components/block-kitchen.tsx | 113 ++++++++++++++++------ src/components/toolbar.tsx | 11 ++- src/index.ts | 4 + src/state/use-block-kit-validation.ts | 6 ++ src/types.ts | 117 ++++++++++++++++++----- test/block-kitchen-compose-only.test.tsx | 82 ++++++++++++++++ 8 files changed, 352 insertions(+), 61 deletions(-) create mode 100644 test/block-kitchen-compose-only.test.tsx diff --git a/README.md b/README.md index 34fb701..a1318d8 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,10 @@ export function MyBuilderPage() { | `workspaceName` | `string` | no | Shown in the preview chrome to mimic a real Slack message header. | | `initialBlocks` | `SupportedBlock[]` | no | Starting draft. If omitted, the builder starts empty. | | `onChange` | `(blocks: SupportedBlock[]) => void` | no | Fires on every state change. Use this to persist the draft (URL, localStorage, etc). | -| `loadChannels` | `() => Promise<{ id: string; name: string }[]>` | yes | Returns channels available to send to. The package never makes Slack API calls itself. | -| `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | yes | Whether the current user has a Slack user-token and can post as themselves. If `canSendAsUser` is false, `oauthUrl` is shown as a "Sign in with Slack" link. | -| `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | yes | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser }`. | +| `onValidationChange` | `(summary: ValidationSummary) => void` | no | Fires when the draft's validation verdict changes (debounced alongside the builder's own validation pass — not on every keystroke). `summary` is `{ valid, errorCount, errors }`: the exact verdict the issues chip/sheet shows, always scoped to the `message` surface. Lets a host-owned CTA gate on validity in [compose-only mode](#compose-only-mode-bring-your-own-send-flow) without re-running the validator. | +| `loadChannels` | `() => Promise<{ id: string; name: string }[]>` | grouped\* | Returns channels available to send to. The package never makes Slack API calls itself. | +| `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | grouped\* | Whether the current user has a Slack user-token and can post as themselves. If `canSendAsUser` is false, `oauthUrl` is shown as a "Sign in with Slack" link. | +| `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | grouped\* | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser }`. | | `editing` | `{ onLoadMessage, onUpdate, loadRecentMessages? }` | no | Opt-in edit mode. When present, the toolbar exposes "Edit message": the user pastes a Slack message link, `onLoadMessage({ link })` returns a host-computed [editability verdict](#editing-an-existing-message-opt-in), and a successful load flips the primary action to "Update message" wired to `onUpdate`. Pass `loadRecentMessages` to add a "recent messages from this app" picker beside the paste input; the user picks a channel first (reusing `loadChannels`) and the lookup is scoped to it. Omit `editing` to keep send-only behavior. | | `loadButtonLabel` | `string` | no | Label + accessible name for the toolbar button that opens the load-message dialog (the edit-mode entry point). Defaults to `'Load message'`. Only shown when `editing` is set and no message is loaded. | | `updateButtonLabel` | `string` | no | Label for the toolbar's primary button while a message is loaded for editing. It's a split button: clicking it updates the message in place; the menu beside it also offers "Send as a new message" (post the current blocks as new). Defaults to `'Review & update'`. | @@ -127,6 +128,64 @@ export function MyBuilderPage() { | `confirmSendLabel` | `string` | no | Label for the send dialog's final confirm button. Defaults to `'Send'` (shows `'Sending…'` while in flight). | | `theme` | `BrandTheme \| BrandPreset` | no | Branding tokens applied to the builder chrome (toolbar, palette, popovers, dialogs). Accepts a `Partial` map and optional `light`/`dark` overrides. See [Styling](#styling) below. | +\* The send trio — `loadChannels`, `loadSendAsUserStatus`, `onSend` — is +all-or-nothing: provide all three for the built-in send flow, or omit all +three for [compose-only mode](#compose-only-mode-bring-your-own-send-flow) +(no send button; your app owns the send flow). Wiring only some of the three +is a type error. `editing` requires the trio. + +## Compose-only mode (bring your own send flow) + +Omit the send trio entirely and the builder renders no send button — it +becomes a pure Block Kit editor. This is the right shape when composing is +one step in a flow your app owns (a wizard, an audience picker, a scheduler): +the package owns *composition* (authoring, preview, validation UX) and your +app owns *distribution* (audiences, identity, scheduling, delivery). The +only thing that crosses the boundary is the blocks array. + +Mirror the draft with `onChange`, gate your own CTA with +`onValidationChange`, and hand `toSlackBlocks(draft)` to whatever comes next: + +```tsx +import { useState } from "react"; +import { + BlockKitchen, + toSlackBlocks, + type SupportedBlock, + type ValidationSummary, +} from "@tightknitai/block-kitchen"; + +function ComposeStep({ onNext }: { onNext: (blocks: SupportedBlock[]) => void }) { + const [draft, setDraft] = useState([]); + const [validation, setValidation] = useState(null); + + return ( + <> + + {/* Your CTA, your copy, your placement — gated on the builder's own verdict. */} + + + ); +} +``` + +Notes: + +- The rest of the toolbar (Clear, View JSON, the issues chip, theme/surface + controls) is unchanged — users can still inspect problems in the issues + sheet; only the moment of commitment moves into your app. +- `onValidationChange` reports the same verdict the issues sheet displays + (validated against the `message` surface, like Send), so your CTA and the + in-builder issue count can never disagree. +- `editing` is unavailable in compose-only mode: updating an existing message + is inherently bound to its channel + timestamp, so the update flow only + exists alongside the send integration. + ## Editing an existing message (opt-in) By default the builder is send-only. Pass `editing` to let users load an @@ -225,7 +284,7 @@ Variant `id`s must be unique across the array — the drag-drop lookup keys by i ## Boundary -The package is deliberately decoupled from any Slack SDK or backend. It does not import HTTP clients, OAuth libraries, or workspace-state systems. Everything I/O-shaped is brokered through props. +The package is deliberately decoupled from any Slack SDK or backend. It does not import HTTP clients, OAuth libraries, or workspace-state systems. Everything I/O-shaped is brokered through props — and the send flow itself is optional: see [compose-only mode](#compose-only-mode-bring-your-own-send-flow) when your app owns the moment of commitment. Helpers also exported: @@ -241,12 +300,16 @@ import type { SupportedBlock, SupportedBlockType, BlockKitchenProps, + BlockKitchenBaseProps, // shared props + BlockKitchenSendProps, // the send trio + `editing` + BlockKitchenComposeOnlyProps, // the trio explicitly absent PaletteSection, PaletteVariant, SendPayload, SendResult, ChannelOption, SendAsUserStatus, + ValidationSummary, PreviewHooks, } from "@tightknitai/block-kitchen"; ``` diff --git a/src/components/block-kitchen.stories.tsx b/src/components/block-kitchen.stories.tsx index 9a40eef..d2dab32 100644 --- a/src/components/block-kitchen.stories.tsx +++ b/src/components/block-kitchen.stories.tsx @@ -2,9 +2,14 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { AlignLeft } from 'lucide-react'; import { expect, fireEvent, fn, userEvent, waitFor, within } from 'storybook/test'; import { defaultPalette, type PaletteSection } from '../lib/default-blocks'; -import type { SupportedBlock } from '../types'; +import type { BlockKitchenBaseProps, BlockKitchenSendProps, SupportedBlock } from '../types'; import { BlockKitchen } from './block-kitchen'; +// Stories exercise the send-enabled configuration; pin that branch of the +// all-or-nothing props union so Storybook's arg inference doesn't collapse +// the union to `never`. +type SendModeProps = BlockKitchenBaseProps & BlockKitchenSendProps; + const STARTER_BLOCKS: SupportedBlock[] = [ { type: 'header', @@ -45,7 +50,7 @@ const meta = { ) ] -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index 58e4dcb..a96b6fe 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -14,7 +14,7 @@ import { } from '@dnd-kit/core'; import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { GripVertical } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { parseContainerBodyId } from '../lib/container-blocks'; import { makeEmojiHook } from '../lib/custom-emoji-hook'; import { buildVariantById, defaultPalette, type PaletteSection } from '../lib/default-blocks'; @@ -24,7 +24,13 @@ import { useIsMobile } from '../lib/use-is-mobile'; import { CustomEmojiProvider } from '../state/custom-emoji-context'; import { useBlockKitValidation } from '../state/use-block-kit-validation'; import { type MoveTarget, useBlockKitchenState } from '../state/use-block-kitchen-state'; -import type { BlockKitchenProps, PreviewSurface, PreviewTheme } from '../types'; +import type { + BlockKitchenBaseProps, + BlockKitchenProps, + BlockKitchenSendProps, + PreviewSurface, + PreviewTheme +} from '../types'; import { BrandThemeScope } from './brand-theme-scope'; import { IssuesSheet } from './issues-sheet'; import { JsonDrawer } from './json-drawer'; @@ -48,6 +54,7 @@ export function BlockKitchen(props: BlockKitchenProps) { workspaceName, initialBlocks, onChange, + onValidationChange, previewHooks, customEmojis, loadChannels, @@ -70,7 +77,11 @@ export function BlockKitchen(props: BlockKitchenProps) { sendButtonLabel, confirmSendLabel, theme - } = props; + // Widen the all-or-nothing union so the send trio destructures as + // independent optionals: untyped JS consumers can still pass partial + // wiring (handled by the runtime guards below), and the correlated-union + // narrowing would otherwise flag those guards as "always true". + } = props as BlockKitchenBaseProps & Partial; const paletteSections = useMemo(() => { const sections = palette ?? defaultPalette; @@ -99,18 +110,41 @@ export function BlockKitchen(props: BlockKitchenProps) { const allowedSurfaces: readonly PreviewSurface[] = allowedSurfacesProp && allowedSurfacesProp.length > 0 ? allowedSurfacesProp : ['message']; + // Compose-only mode: the send trio is all-or-nothing (enforced at the type + // level by `BlockKitchenProps`). When absent, the toolbar renders no send + // button and the send/edit dialogs never mount — the builder is a pure + // editor and the host owns the send flow via `onChange` + + // `onValidationChange`. + const sendEnabled = Boolean(loadChannels && loadSendAsUserStatus && onSend); + // Edit mode depends on the send integration (channel list, user-token + // status, the update dialog), so it only counts when the trio is wired. + const editingConfig = sendEnabled ? editing : undefined; + // A pre-loaded edit target (opt-in) carries its own blocks; they seed the // draft and win over `initialBlocks`, which is the blank-canvas seed. - const seededBlocks = editing?.initialTarget?.blocks ?? initialBlocks; - // Mount-only: both props are read once at mount, so warn once. + const seededBlocks = editingConfig?.initialTarget?.blocks ?? initialBlocks; + // Mount-only: these props are read once at mount, so warn once. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only check useEffect(() => { - if (editing?.initialTarget && initialBlocks) { + if (editingConfig?.initialTarget && initialBlocks) { console.warn( '[BlockKitchen] Both `initialBlocks` and `editing.initialTarget` were provided; ' + 'using the target’s blocks and ignoring `initialBlocks`.' ); } + const sendPropsProvided = [loadChannels, loadSendAsUserStatus, onSend].filter(Boolean).length; + if (sendPropsProvided > 0 && sendPropsProvided < 3) { + console.warn( + '[BlockKitchen] Partial send wiring: `loadChannels`, `loadSendAsUserStatus`, and `onSend` ' + + 'are all-or-nothing. The send button is hidden until all three are provided.' + ); + } + if (editing && !sendEnabled) { + console.warn( + '[BlockKitchen] `editing` requires the send integration (`loadChannels`, ' + + '`loadSendAsUserStatus`, `onSend`); ignoring `editing`.' + ); + } }, []); const { blocks, addBlock, addChild, updateBlock, removeBlock, duplicateBlock, reorderBlock, moveBlock, replaceAll } = @@ -138,11 +172,11 @@ export function BlockKitchen(props: BlockKitchenProps) { // the current blocks as a new message (`sendOpen`). const [updateOpen, setUpdateOpen] = useState(false); const [loadOpen, setLoadOpen] = useState(false); - const [editTarget, setEditTarget] = useState(() => editing?.initialTarget ?? null); + const [editTarget, setEditTarget] = useState(() => editingConfig?.initialTarget ?? null); // Edit mode only counts as active while `editing` is configured. If the host // toggles `editing` off mid-session, fall back to send-only without losing // the loaded target (it reactivates if `editing` returns). - const activeEditTarget = editing ? editTarget : null; + const activeEditTarget = editingConfig ? editTarget : null; const [issuesOpen, setIssuesOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [openBlockId, setOpenBlockId] = useState(null); @@ -177,6 +211,23 @@ export function BlockKitchen(props: BlockKitchenProps) { // Send accept a payload Slack will reject. const validation = useBlockKitValidation(blocks, 'message'); + // Report the verdict to the host only when it actually changes: the hook + // re-runs (with a fresh object identity) on every debounced pass, and the + // callback prop is often an inline arrow, so both are deduped against the + // last-notified key rather than used as change signals themselves. + const lastNotifiedValidationRef = useRef(null); + useEffect(() => { + if (!onValidationChange) { + return; + } + const key = JSON.stringify([validation.valid, validation.total, ...validation.errors]); + if (lastNotifiedValidationRef.current === key) { + return; + } + lastNotifiedValidationRef.current = key; + onValidationChange({ valid: validation.valid, errorCount: validation.total, errors: validation.errors }); + }, [validation, onValidationChange]); + // Touch needs a 150ms press-and-hold to start a drag so scrolling the // surface doesn't accidentally pick up a block. Pointer keeps the small // 4px distance threshold so mouse clicks still open the editor cleanly. @@ -310,8 +361,9 @@ export function BlockKitchen(props: BlockKitchenProps) { // host app fully owns the theme; otherwise honor the prop. showThemeControl={isPreviewThemeControlled ? false : showThemeControl} docsLink={docsLink} + showSend={sendEnabled} sendButtonLabel={sendButtonLabel} - editingEnabled={!!editing} + editingEnabled={!!editingConfig} editBadge={ activeEditTarget ? { @@ -407,29 +459,32 @@ export function BlockKitchen(props: BlockKitchenProps) { {/* The Send dialog always posts a brand-new message (used by both plain Send and the edit-mode "Send as a new message"). The Update - dialog (channel locked) only exists while a message is loaded. */} - { - setSendOpen(false); - setIssuesOpen(true); - }} - /> - {activeEditTarget && editing ? ( + dialog (channel locked) only exists while a message is loaded. + None of the three mount in compose-only mode. */} + {loadChannels && loadSendAsUserStatus && onSend ? ( + { + setSendOpen(false); + setIssuesOpen(true); + }} + /> + ) : null} + {activeEditTarget && editingConfig && loadSendAsUserStatus ? ( { @@ -438,12 +493,12 @@ export function BlockKitchen(props: BlockKitchenProps) { }} /> ) : null} - {editing ? ( + {editingConfig && loadChannels ? ( { replaceAll(result.blocks); diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 61ccd8c..5fc1796 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -84,6 +84,8 @@ const SEND_MENU_ITEM = * an object overrides `href` and/or `label`. Defaults to the Slack Block * Kit reference docs. * @param props.errorCount - number of validation errors + * @param props.showSend - whether the send action renders at all (default + * true; false in compose-only mode) * @param props.sendButtonLabel - label + accessible name for the Send * button that opens the send dialog. Defaults to `'Send'`. * @returns the rendered toolbar @@ -104,6 +106,7 @@ export function Toolbar({ showThemeControl = true, docsLink, errorCount, + showSend = true, sendButtonLabel = 'Review & send', editingEnabled = false, editBadge, @@ -128,6 +131,12 @@ export function Toolbar({ showThemeControl?: boolean; docsLink?: false | { href?: string; label?: string }; errorCount: number; + /** + * Whether the send action (single button, or split button in edit mode) + * renders at all. `false` in compose-only mode, where the host owns the + * send flow. Defaults to `true`. + */ + showSend?: boolean; sendButtonLabel?: string; /** Whether edit mode is configured (shows the "Edit existing message" entry). */ editingEnabled?: boolean; @@ -277,7 +286,7 @@ export function Toolbar({ View JSON - {editBadge ? ( + {!showSend ? null : editBadge ? ( // A message is loaded: split button. Main action updates it in // place; the menu also offers posting the blocks as a new message.
diff --git a/src/index.ts b/src/index.ts index b421125..71d1411 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,10 @@ export { export type { AlertBlock, AlertLevel, + BlockKitchenBaseProps, + BlockKitchenComposeOnlyProps, BlockKitchenProps, + BlockKitchenSendProps, BuilderBlock, CardBlock, CarouselBlock, @@ -65,5 +68,6 @@ export type { UpdatePayload, UpdateResult, UrlSourceElement, + ValidationSummary, VideoBlock } from './types'; diff --git a/src/state/use-block-kit-validation.ts b/src/state/use-block-kit-validation.ts index 30e86b0..345f865 100644 --- a/src/state/use-block-kit-validation.ts +++ b/src/state/use-block-kit-validation.ts @@ -18,6 +18,11 @@ const DEBOUNCE_MS = 150; export interface ValidationState extends GroupedErrors { /** True iff there are zero errors after the most recent run. */ valid: boolean; + /** + * Raw validator messages, verbatim (block-scoped ones are `blocks[N]`- + * rooted). Forwarded to the host through `onValidationChange`. + */ + errors: readonly string[]; } /** @@ -36,6 +41,7 @@ function computeValidation(blocks: BuilderBlock[], surface: PreviewSurface): Val const grouped = groupValidatorErrors(result.errors, blocks); return { valid: result.valid, + errors: result.errors, byBlockId: grouped.byBlockId, general: grouped.general, total: grouped.total diff --git a/src/types.ts b/src/types.ts index 99e476b..2d2d1ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -497,6 +497,26 @@ export interface SendResult { error?: string; } +/** + * Snapshot of the builder's validation verdict, reported through + * {@link BlockKitchenBaseProps.onValidationChange}. Mirrors exactly what the + * built-in issues chip / issues sheet show, so a host-owned CTA (e.g. in + * compose-only mode) can gate on the same verdict without re-running the + * validator and risking drift. + */ +export interface ValidationSummary { + /** True iff the current draft has zero validation errors. */ + valid: boolean; + /** Total error count — the number the toolbar's issues chip displays. */ + errorCount: number; + /** + * Raw validator messages, verbatim from + * `@tightknitai/slack-block-kit-validator`. Block-scoped messages are + * rooted at `blocks[N]`, indexing into the payload `toSlackBlocks` emits. + */ + errors: readonly string[]; +} + /** * Which token can edit a loaded message, as computed by the host. * `chat.update` only edits a message authored by the calling token: @@ -723,11 +743,15 @@ export interface Template { } /** - * Props for the top-level {@link BlockKitchen} component. + * Props shared by every {@link BlockKitchen} configuration. * The package is integration-agnostic: every I/O concern is brokered * through these props. The component never makes a network call itself. + * + * Combined with either {@link BlockKitchenSendProps} (the built-in send + * flow) or {@link BlockKitchenComposeOnlyProps} (compose-only: no send + * button, the host owns the send flow) to form {@link BlockKitchenProps}. */ -export interface BlockKitchenProps { +export interface BlockKitchenBaseProps { /** * Workspace name shown in the preview chrome to mimic a Slack message header. * Cosmetic only. @@ -742,6 +766,15 @@ export interface BlockKitchenProps { * (URL search param, localStorage, etc). */ onChange?: (blocks: SupportedBlock[]) => void; + /** + * Fires when the draft's validation verdict changes (piggybacking on the + * builder's own debounced validation pass — not on every keystroke, and + * not when the verdict is unchanged). Always scoped to the `message` + * surface, matching what Send would post. Useful in compose-only mode: a + * host-rendered CTA can disable itself on `!valid` using the exact verdict + * the issues sheet displays. + */ + onValidationChange?: (summary: ValidationSummary) => void; /** * Optional hooks forwarded to the underlying `` component for * resolving user / channel / emoji directives. If omitted, those @@ -759,29 +792,6 @@ export interface BlockKitchenProps { * is never serialized into the emitted Block Kit JSON. */ customEmojis?: CustomEmoji[]; - /** - * Returns channels available to send to. Called when the send dialog opens. - */ - loadChannels: () => Promise; - /** - * Returns whether the current user can post as themselves and, if not, an - * OAuth URL to start the install flow. - */ - loadSendAsUserStatus: () => Promise; - /** - * Called when the user submits the send dialog. Should return - * `{ ok: true }` on success or `{ ok: false, error }` on failure. - */ - onSend: (payload: SendPayload) => Promise; - /** - * Opt-in edit mode. When provided, the toolbar exposes an "Edit existing - * message" entry: the user pastes a Slack message link, the host loads it - * and returns an editability verdict ({@link EditingConfig.onLoadMessage}), - * and a successful load flips the primary action from Send to "Update - * message" ({@link EditingConfig.onUpdate}). Omit to keep today's send-only - * behavior. The user-token path reuses {@link BlockKitchenProps.loadSendAsUserStatus}. - */ - editing?: EditingConfig; /** * Label + accessible name for the toolbar button that opens the * load-message dialog (the edit-mode entry point). Defaults to @@ -948,3 +958,60 @@ export interface BlockKitchenProps { */ theme?: BrandTheme | BrandPreset; } + +/** + * The send integration: the trio of I/O callbacks powering the built-in + * send dialog, plus opt-in edit mode (which reuses them). All-or-nothing by + * design — see {@link BlockKitchenComposeOnlyProps} for the alternative. + */ +export interface BlockKitchenSendProps { + /** + * Returns channels available to send to. Called when the send dialog opens. + */ + loadChannels: () => Promise; + /** + * Returns whether the current user can post as themselves and, if not, an + * OAuth URL to start the install flow. + */ + loadSendAsUserStatus: () => Promise; + /** + * Called when the user submits the send dialog. Should return + * `{ ok: true }` on success or `{ ok: false, error }` on failure. + */ + onSend: (payload: SendPayload) => Promise; + /** + * Opt-in edit mode. When provided, the toolbar exposes an "Edit existing + * message" entry: the user pastes a Slack message link, the host loads it + * and returns an editability verdict ({@link EditingConfig.onLoadMessage}), + * and a successful load flips the primary action from Send to "Update + * message" ({@link EditingConfig.onUpdate}). Omit to keep send-only + * behavior. The user-token path reuses {@link BlockKitchenSendProps.loadSendAsUserStatus}. + */ + editing?: EditingConfig; +} + +/** + * Compose-only mode: omit the entire send integration and the builder + * renders no send button — it becomes a pure editor. The host owns the + * moment of commitment: mirror the draft via + * {@link BlockKitchenBaseProps.onChange}, gate a host-rendered CTA via + * {@link BlockKitchenBaseProps.onValidationChange}, and hand the finished + * payload (`toSlackBlocks(draft)`) to your own audience/delivery flow. + * + * The explicit `undefined` members make the send trio all-or-nothing at the + * type level: wiring only some of the three is a type error instead of a + * silently dead send button. + */ +export interface BlockKitchenComposeOnlyProps { + loadChannels?: undefined; + loadSendAsUserStatus?: undefined; + onSend?: undefined; + /** Edit mode depends on the send integration, so it is unavailable too. */ + editing?: undefined; +} + +/** + * Props for the top-level {@link BlockKitchen} component: the shared base + * plus either the full send integration or none of it. + */ +export type BlockKitchenProps = BlockKitchenBaseProps & (BlockKitchenSendProps | BlockKitchenComposeOnlyProps); diff --git a/test/block-kitchen-compose-only.test.tsx b/test/block-kitchen-compose-only.test.tsx new file mode 100644 index 0000000..baaaa8b --- /dev/null +++ b/test/block-kitchen-compose-only.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { BlockKitchen } from '../src/components/block-kitchen'; +import type { BlockKitchenProps, SupportedBlock, ValidationSummary } from '../src/types'; + +const sendProps = { + loadChannels: async () => [{ id: 'C1', name: 'general' }], + loadSendAsUserStatus: async () => ({ canSendAsUser: false, oauthUrl: 'https://example.com/oauth' }), + onSend: async () => ({ ok: true }) +}; + +afterEach(() => vi.restoreAllMocks()); + +it('renders no send button in compose-only mode', () => { + render(); + expect(screen.queryByRole('button', { name: /review & send/i })).toBeNull(); + // The rest of the builder chrome is unaffected. + expect(screen.getByRole('button', { name: /view json/i })).toBeTruthy(); +}); + +it('renders the send button when the send trio is wired', () => { + render(); + expect(screen.getByRole('button', { name: /review & send/i })).toBeTruthy(); +}); + +it('reports a clean verdict through onValidationChange for a valid draft', () => { + const onValidationChange = vi.fn<(summary: ValidationSummary) => void>(); + render( + + ); + expect(onValidationChange).toHaveBeenCalledTimes(1); + expect(onValidationChange).toHaveBeenCalledWith({ valid: true, errorCount: 0, errors: [] }); +}); + +it('reports errors through onValidationChange for an invalid draft', () => { + const onValidationChange = vi.fn<(summary: ValidationSummary) => void>(); + // A bare section (no text, no fields) is invalid Block Kit. + render( + + ); + expect(onValidationChange).toHaveBeenCalledTimes(1); + const summary = onValidationChange.mock.calls[0][0]; + expect(summary.valid).toBe(false); + expect(summary.errorCount).toBeGreaterThan(0); + expect(summary.errors.length).toBe(summary.errorCount); +}); + +it('warns on partial send wiring and hides the send button', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const partial = { loadChannels: sendProps.loadChannels } as unknown as BlockKitchenProps; + render(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/all-or-nothing/)); + expect(screen.queryByRole('button', { name: /review & send/i })).toBeNull(); +}); + +it('warns and ignores `editing` when the send trio is absent', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const editingOnly = { + editing: { + onLoadMessage: async () => ({ ok: false as const, reason: 'n/a' }), + onUpdate: async () => ({ ok: true }) + } + } as unknown as BlockKitchenProps; + render(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/`editing` requires the send integration/)); + expect(screen.queryByRole('button', { name: /find message/i })).toBeNull(); +}); + +// Type-level guarantees: the send trio is all-or-nothing, and `editing` +// requires it. These lines are enforced by `pnpm typecheck` (tsc runs over +// the test tsconfig), not by vitest. +// @ts-expect-error partial send wiring is rejected — the trio is all-or-nothing +const partialWiring: BlockKitchenProps = { loadChannels: sendProps.loadChannels }; +// @ts-expect-error `editing` is unavailable without the send trio +const editingWithoutSend: BlockKitchenProps = { + editing: { + onLoadMessage: async () => ({ ok: false, reason: 'n/a' }), + onUpdate: async () => ({ ok: true }) + } +}; +void partialWiring; +void editingWithoutSend; From 122c2d3c67447246af5f99a03a6d5f849f466cc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 11:00:52 +0000 Subject: [PATCH 2/2] feat: send-dialog extras slot, compose-only primaryAction, exported send primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three extension points layered on compose-only mode, so callers can customize the moment of commitment at any grain: - renderSendExtras (send mode): host-defined fields rendered inside the built-in send dialog below the channel/identity pickers; values collected via setExtras arrive on onSend as payload.extras. The key is only present when the slot is wired, so existing onSend handlers are unaffected. The extras object is dialog-owned and resets on open. - primaryAction (compose-only mode): host-owned button rendered in the toolbar slot where "Review & send" normally sits (e.g. "Save template" for a message drafter). onClick receives the current draft plus the same validation verdict onValidationChange reports; disableWhenInvalid opts into Send-style gating (default off). - exported send-flow primitives: SendDialog (+ SendDialogProps), useSlackSignIn, SlackSignInButton — so a bespoke send flow built on compose-only mode doesn't rebuild channel loading, OAuth polling, and sending states from scratch. Both new props follow the union's all-or-nothing discipline: wrong-branch use is a type error, with mount-time console.warn guards for untyped consumers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019Av2NwEDuB6sw2Q6ab35bH --- README.md | 118 ++++++++++++++++++++++- src/components/block-kitchen.tsx | 60 ++++++++++-- src/components/send-dialog.tsx | 91 +++++++++++------ src/components/toolbar.tsx | 28 +++++- src/index.ts | 6 ++ src/types.ts | 102 ++++++++++++++++++++ test/block-kitchen-compose-only.test.tsx | 82 +++++++++++++++- test/public-api.test.ts | 14 +++ test/send-dialog-extras.test.tsx | 99 +++++++++++++++++++ 9 files changed, 551 insertions(+), 49 deletions(-) create mode 100644 test/send-dialog-extras.test.tsx diff --git a/README.md b/README.md index a1318d8..393f29e 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,9 @@ export function MyBuilderPage() { | `onValidationChange` | `(summary: ValidationSummary) => void` | no | Fires when the draft's validation verdict changes (debounced alongside the builder's own validation pass — not on every keystroke). `summary` is `{ valid, errorCount, errors }`: the exact verdict the issues chip/sheet shows, always scoped to the `message` surface. Lets a host-owned CTA gate on validity in [compose-only mode](#compose-only-mode-bring-your-own-send-flow) without re-running the validator. | | `loadChannels` | `() => Promise<{ id: string; name: string }[]>` | grouped\* | Returns channels available to send to. The package never makes Slack API calls itself. | | `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | grouped\* | Whether the current user has a Slack user-token and can post as themselves. If `canSendAsUser` is false, `oauthUrl` is shown as a "Sign in with Slack" link. | -| `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | grouped\* | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser }`. | +| `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | grouped\* | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser, extras? }` (`extras` only when `renderSendExtras` is wired). | +| `renderSendExtras` | `(ctx: SendExtrasContext) => ReactNode` | no | Renders host-defined fields inside the built-in send dialog, below the channel and identity pickers. Collect values with `ctx.setExtras(patch)`; they arrive on `onSend` as `payload.extras`. Requires the send trio. See [Extending the send dialog](#extending-the-send-dialog-custom-fields). | +| `primaryAction` | `{ label, onClick, disableWhenInvalid? }` | no | Compose-only mode only: a host-owned button in the toolbar slot where "Review & send" normally sits. `onClick` receives `{ blocks, validation }` — the current draft plus the same verdict `onValidationChange` reports. See [Keeping the CTA in the toolbar](#keeping-the-cta-in-the-toolbar-primaryaction). | | `editing` | `{ onLoadMessage, onUpdate, loadRecentMessages? }` | no | Opt-in edit mode. When present, the toolbar exposes "Edit message": the user pastes a Slack message link, `onLoadMessage({ link })` returns a host-computed [editability verdict](#editing-an-existing-message-opt-in), and a successful load flips the primary action to "Update message" wired to `onUpdate`. Pass `loadRecentMessages` to add a "recent messages from this app" picker beside the paste input; the user picks a channel first (reusing `loadChannels`) and the lookup is scoped to it. Omit `editing` to keep send-only behavior. | | `loadButtonLabel` | `string` | no | Label + accessible name for the toolbar button that opens the load-message dialog (the edit-mode entry point). Defaults to `'Load message'`. Only shown when `editing` is set and no message is loaded. | | `updateButtonLabel` | `string` | no | Label for the toolbar's primary button while a message is loaded for editing. It's a split button: clicking it updates the message in place; the menu beside it also offers "Send as a new message" (post the current blocks as new). Defaults to `'Review & update'`. | @@ -132,7 +134,9 @@ export function MyBuilderPage() { all-or-nothing: provide all three for the built-in send flow, or omit all three for [compose-only mode](#compose-only-mode-bring-your-own-send-flow) (no send button; your app owns the send flow). Wiring only some of the three -is a type error. `editing` requires the trio. +is a type error. `editing` and `renderSendExtras` require the trio; +`primaryAction` requires its absence (the built-in Send/Update flow owns the +toolbar's primary slot otherwise). ## Compose-only mode (bring your own send flow) @@ -186,6 +190,103 @@ Notes: is inherently bound to its channel + timestamp, so the update flow only exists alongside the send integration. +### Keeping the CTA in the toolbar (`primaryAction`) + +The example above renders its CTA outside the builder. If you want the button +to sit where "Review & send" normally sits — say a message drafter whose +primary action is "Save template" — pass `primaryAction`: + +```tsx + openSaveModal(blocks, validation), + // Send-style gating is opt-in. Default is enabled-while-invalid: a + // drafter usually allows committing a work-in-progress draft and + // surfaces the verdict in its own flow instead. + disableWhenInvalid: false, + }} +/> +``` + +`onClick` receives the current draft in the builder's native format (the same +shape `onChange` reports — persist that to re-open the draft later; run it +through `toSlackBlocks` for the Slack-ready payload) plus the validation +verdict `onValidationChange` would report. The button is never disabled for +an empty draft; gate on `blocks.length` in `onClick` if you need that. + +### Building a bespoke send flow from the exported primitives + +If your custom flow ends in something send-shaped, you don't have to rebuild +the built-in dialog's machinery — the send-flow primitives are exported: + +- `SendDialog` — the built-in dialog as a standalone component + (`SendDialogProps`): channel loading/error states, bot-vs-user identity + picker, OAuth hand-off, in-flight and failure states. Mount it against your + own trigger. +- `useSlackSignIn(loadStatus, { open, enabled })` — the OAuth sign-in state + machine behind the identity picker: fetches user-token status on open, + refreshes on window focus, and background-polls after the OAuth tab opens + until the token appears. +- `SlackSignInButton` — the "Sign in with Slack" button with its polling + spinner, for wiring `useSlackSignIn` into your own dialog. + +## Extending the send dialog (custom fields) + +When the built-in send flow is *almost* right — you want the channel picker, +identity picker, and sending states, plus a few controls of your own (a +cross-post toggle, a custom sender name) — pass `renderSendExtras` instead of +rebuilding the dialog. It renders below the built-in fields, and whatever you +collect via `setExtras` arrives on `onSend` as `payload.extras`: + +```tsx + ( + <> + + + + )} + onSend={async ({ channelId, blocks, sendAsUser, extras }) => { + await api.send({ channelId, blocks, sendAsUser, ...extras }); // your code + return { ok: true }; + }} +/> +``` + +Notes: + +- The `extras` object is owned by the dialog: it resets every time the dialog + opens (alongside the channel and identity pickers), and `setExtras(patch)` + shallow-merges. Render controlled inputs from `extras`. +- The context also carries the currently selected `channelId` (`null` until + channels load) and `sendAsUser`, so extras can react to them — e.g. only + offer cross-posting for certain channels. +- `payload.extras` is present iff `renderSendExtras` is wired, so existing + `onSend` handlers never see a new key. +- The dialog renders in a portal attached to `document.body`, so CSS that + relies on ancestor selectors from your app's DOM won't reach slot content — + style it directly (inline, CSS modules, utility classes). +- For customizations that generalize something the dialog already models + (identities, channels), prefer proposing a first-class prop over routing + them through extras; the slot is for genuinely host-specific concerns. + ## Editing an existing message (opt-in) By default the builder is send-only. Pass `editing` to let users load an @@ -286,7 +387,7 @@ Variant `id`s must be unique across the array — the drag-drop lookup keys by i The package is deliberately decoupled from any Slack SDK or backend. It does not import HTTP clients, OAuth libraries, or workspace-state systems. Everything I/O-shaped is brokered through props — and the send flow itself is optional: see [compose-only mode](#compose-only-mode-bring-your-own-send-flow) when your app owns the moment of commitment. -Helpers also exported: +Helpers and send-flow primitives also exported: ```ts import { @@ -294,6 +395,9 @@ import { encodeBlocksToString, // base64url-encode a blocks array (for URL state) decodeBlocksFromString, defaultPalette, // the built-in palette — spread to customize + SendDialog, // the built-in send dialog, standalone (bespoke send flows) + useSlackSignIn, // the OAuth sign-in state machine behind the identity picker + SlackSignInButton, // the "Sign in with Slack" button + polling spinner } from "@tightknitai/block-kitchen"; import type { @@ -301,12 +405,16 @@ import type { SupportedBlockType, BlockKitchenProps, BlockKitchenBaseProps, // shared props - BlockKitchenSendProps, // the send trio + `editing` - BlockKitchenComposeOnlyProps, // the trio explicitly absent + BlockKitchenSendProps, // the send trio + `editing` + `renderSendExtras` + BlockKitchenComposeOnlyProps, // the trio explicitly absent + `primaryAction` PaletteSection, PaletteVariant, SendPayload, SendResult, + SendDialogProps, + SendExtrasContext, + PrimaryActionConfig, + PrimaryActionContext, ChannelOption, SendAsUserStatus, ValidationSummary, diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index a96b6fe..2296367 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -26,10 +26,12 @@ import { useBlockKitValidation } from '../state/use-block-kit-validation'; import { type MoveTarget, useBlockKitchenState } from '../state/use-block-kitchen-state'; import type { BlockKitchenBaseProps, + BlockKitchenComposeOnlyProps, BlockKitchenProps, BlockKitchenSendProps, PreviewSurface, - PreviewTheme + PreviewTheme, + ValidationSummary } from '../types'; import { BrandThemeScope } from './brand-theme-scope'; import { IssuesSheet } from './issues-sheet'; @@ -60,7 +62,9 @@ export function BlockKitchen(props: BlockKitchenProps) { loadChannels, loadSendAsUserStatus, onSend, + renderSendExtras, editing, + primaryAction, loadButtonLabel, updateButtonLabel, confirmUpdateLabel, @@ -77,11 +81,16 @@ export function BlockKitchen(props: BlockKitchenProps) { sendButtonLabel, confirmSendLabel, theme - // Widen the all-or-nothing union so the send trio destructures as - // independent optionals: untyped JS consumers can still pass partial - // wiring (handled by the runtime guards below), and the correlated-union - // narrowing would otherwise flag those guards as "always true". - } = props as BlockKitchenBaseProps & Partial; + // Widen the all-or-nothing union so the branch-specific props + // destructure as independent optionals: untyped JS consumers can still + // pass partial wiring (handled by the runtime guards below), and the + // correlated-union narrowing would otherwise flag those guards as + // "always true". `primaryAction` is pulled from the compose-only branch + // (where it carries a real type) rather than the send branch's + // `undefined` pin, which would collapse the intersection to `undefined`. + } = props as BlockKitchenBaseProps & + Partial> & + Pick; const paletteSections = useMemo(() => { const sections = palette ?? defaultPalette; @@ -145,6 +154,19 @@ export function BlockKitchen(props: BlockKitchenProps) { '`loadSendAsUserStatus`, `onSend`); ignoring `editing`.' ); } + if (renderSendExtras && !sendEnabled) { + console.warn( + '[BlockKitchen] `renderSendExtras` extends the built-in send dialog, which requires the ' + + 'send integration (`loadChannels`, `loadSendAsUserStatus`, `onSend`); ignoring `renderSendExtras`.' + ); + } + if (primaryAction && sendEnabled) { + console.warn( + '[BlockKitchen] `primaryAction` is only available in compose-only mode — with the send ' + + 'integration wired, the built-in Send/Update flow owns the toolbar’s primary action; ' + + 'ignoring `primaryAction`.' + ); + } }, []); const { blocks, addBlock, addChild, updateBlock, removeBlock, duplicateBlock, reorderBlock, moveBlock, replaceAll } = @@ -211,6 +233,13 @@ export function BlockKitchen(props: BlockKitchenProps) { // Send accept a payload Slack will reject. const validation = useBlockKitValidation(blocks, 'message'); + // The host-facing snapshot of the verdict: what `onValidationChange` + // reports and what a compose-only `primaryAction` click receives. + const validationSummary = useMemo( + () => ({ valid: validation.valid, errorCount: validation.total, errors: validation.errors }), + [validation] + ); + // Report the verdict to the host only when it actually changes: the hook // re-runs (with a fresh object identity) on every debounced pass, and the // callback prop is often an inline arrow, so both are deduped against the @@ -220,13 +249,13 @@ export function BlockKitchen(props: BlockKitchenProps) { if (!onValidationChange) { return; } - const key = JSON.stringify([validation.valid, validation.total, ...validation.errors]); + const key = JSON.stringify([validationSummary.valid, validationSummary.errorCount, ...validationSummary.errors]); if (lastNotifiedValidationRef.current === key) { return; } lastNotifiedValidationRef.current = key; - onValidationChange({ valid: validation.valid, errorCount: validation.total, errors: validation.errors }); - }, [validation, onValidationChange]); + onValidationChange(validationSummary); + }, [validationSummary, onValidationChange]); // Touch needs a 150ms press-and-hold to start a drag so scrolling the // surface doesn't accidentally pick up a block. Pointer keeps the small @@ -362,6 +391,18 @@ export function BlockKitchen(props: BlockKitchenProps) { showThemeControl={isPreviewThemeControlled ? false : showThemeControl} docsLink={docsLink} showSend={sendEnabled} + // Compose-only mode only: with the send integration wired, + // the built-in Send/Update flow owns the primary slot (and a + // mount-time warning covers untyped consumers passing both). + primaryAction={ + !sendEnabled && primaryAction + ? { + label: primaryAction.label, + onClick: () => primaryAction.onClick({ blocks: blockPayloads, validation: validationSummary }), + disabled: primaryAction.disableWhenInvalid ? !validationSummary.valid : false + } + : null + } sendButtonLabel={sendButtonLabel} editingEnabled={!!editingConfig} editBadge={ @@ -469,6 +510,7 @@ export function BlockKitchen(props: BlockKitchenProps) { loadChannels={loadChannels} loadSendAsUserStatus={loadSendAsUserStatus} onSend={onSend} + renderSendExtras={renderSendExtras} confirmSendLabel={confirmSendLabel} errorCount={validation.total} onShowIssues={() => { diff --git a/src/components/send-dialog.tsx b/src/components/send-dialog.tsx index 2804d2a..3223e71 100644 --- a/src/components/send-dialog.tsx +++ b/src/components/send-dialog.tsx @@ -1,31 +1,54 @@ import { AlertTriangle } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { toSlackBlocks } from '../lib/to-slack-blocks'; import { Button } from '../lib/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../lib/ui/dialog'; import { Label } from '../lib/ui/label'; import { isSafeHref } from '../lib/url-safety'; -import type { ChannelOption, SendAsUserStatus, SendPayload, SupportedBlock } from '../types'; +import type { ChannelOption, SendAsUserStatus, SendExtrasContext, SendPayload, SupportedBlock } from '../types'; import { SlackSignInButton, useSlackSignIn } from './slack-sign-in'; type SendStatus = { kind: 'idle' } | { kind: 'sending' } | { kind: 'success' } | { kind: 'error'; error: string }; /** - * Modal dialog that collects the destination channel + send-as identity, - * then calls the consumer's `onSend`. + * Props for {@link SendDialog}. Exported so hosts building a bespoke send + * flow (compose-only mode) can reuse the built-in dialog standalone instead + * of rebuilding channel loading, identity, and sending states from scratch. + */ +export interface SendDialogProps { + /** Whether the dialog is open. */ + open: boolean; + /** Notified when the user closes the dialog. */ + onOpenChange: (open: boolean) => void; + /** The draft blocks to send (builder-native format). */ + blocks: SupportedBlock[]; + /** Returns channels available to send to. Called when the dialog opens. */ + loadChannels: () => Promise; + /** Returns user-token status + OAuth URL. */ + loadSendAsUserStatus: () => Promise; + /** Terminal action; should return `{ ok }` or `{ ok: false, error }`. */ + onSend: (payload: SendPayload) => Promise<{ ok: boolean; error?: string }>; + /** + * Renders host-defined fields below the built-in pickers; collected + * values are delivered on {@link SendPayload.extras}. + */ + renderSendExtras?: (context: SendExtrasContext) => ReactNode; + /** Label for the final confirm button. Defaults to `'Send'`. */ + confirmSendLabel?: string; + /** Total validation errors against the current draft. */ + errorCount: number; + /** Asks the parent to open the global issues panel. */ + onShowIssues?: () => void; +} + +/** + * Modal dialog that collects the destination channel + send-as identity + * (plus any host-defined extras), then calls the consumer's `onSend`. * * Channels and user-token status are loaded async via callback props on open. * The consumer brokers all I/O; the dialog never makes a network call. - * @param props - dialog props - * @param props.open - whether the dialog is open - * @param props.onOpenChange - notified when the user closes the dialog - * @param props.blocks - the draft blocks to send - * @param props.loadChannels - returns channels available to send to - * @param props.loadSendAsUserStatus - returns user-token status + OAuth URL - * @param props.onSend - terminal action; should return `{ ok }` or `{ ok: false, error }` - * @param props.confirmSendLabel - label for the final confirm button. Defaults to `'Send'`. - * @param props.errorCount - total validation errors against the current draft - * @param props.onShowIssues - called when the user opens the global issues panel + * @param props - {@link SendDialogProps} * @returns the rendered send dialog */ export function SendDialog({ @@ -35,28 +58,23 @@ export function SendDialog({ loadChannels, loadSendAsUserStatus, onSend, + renderSendExtras, confirmSendLabel = 'Send', errorCount, onShowIssues -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - blocks: SupportedBlock[]; - loadChannels: () => Promise; - loadSendAsUserStatus: () => Promise; - onSend: (payload: SendPayload) => Promise<{ ok: boolean; error?: string }>; - /** Label for the final confirm button. Defaults to `'Send'`. */ - confirmSendLabel?: string; - /** Total validation errors against the current draft. */ - errorCount: number; - /** Asks the parent to open the global issues panel. */ - onShowIssues?: () => void; -}) { +}: SendDialogProps) { const [channels, setChannels] = useState(null); const [channelsError, setChannelsError] = useState(null); const [channelId, setChannelId] = useState(''); const [sendAs, setSendAs] = useState<'bot' | 'user'>('bot'); const [status, setStatus] = useState({ kind: 'idle' }); + // Host-defined extras (see `renderSendExtras`). Dialog-owned so the values + // reset on open alongside the channel/identity pickers, and so `onSend` + // receives them in the payload rather than relying on host closures. + const [extras, setExtrasState] = useState>({}); + const setExtras = useCallback((patch: Record) => { + setExtrasState((prev) => ({ ...prev, ...patch })); + }, []); const { userStatus, polling, startSignIn } = useSlackSignIn(loadSendAsUserStatus, { open, enabled: true }); @@ -77,6 +95,7 @@ export function SendDialog({ setChannelsError(null); setChannelId(''); setSendAs('bot'); + setExtrasState({}); let cancelled = false; loadChannelsRef .current() @@ -108,7 +127,10 @@ export function SendDialog({ const result = await onSend({ channelId, blocks: toSlackBlocks(blocks), - sendAsUser: sendAs === 'user' + sendAsUser: sendAs === 'user', + // Only introduce the key when the extras slot is wired, so existing + // consumers never see a new payload field. + ...(renderSendExtras ? { extras } : {}) }); if (result.ok) { setStatus({ kind: 'idle' }); @@ -182,6 +204,17 @@ export function SendDialog({ )}
+ {renderSendExtras ? ( +
+ {renderSendExtras({ + channelId: channelId || null, + sendAsUser: sendAs === 'user', + extras, + setExtras + })} +
+ ) : null} + {errorCount > 0 ? ( - {!showSend ? null : editBadge ? ( + {!showSend ? ( + primaryAction ? ( + // Compose-only mode with a host-owned primary action: same + // placement and styling as the built-in send button. No icon — + // the action's meaning is the host's, so the label always shows + // (the send button can collapse to its icon on small screens; + // an icon-less button can't). + + ) : null + ) : editBadge ? ( // A message is loaded: split button. Main action updates it in // place; the menu also offers posting the blocks as a new message.
diff --git a/src/index.ts b/src/index.ts index 71d1411..24fcdb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ export { BlockKitchen } from './components/block-kitchen'; +export type { SendDialogProps } from './components/send-dialog'; +export { SendDialog } from './components/send-dialog'; +export { SlackSignInButton, useSlackSignIn } from './components/slack-sign-in'; export { TemplatePicker } from './components/template-picker'; export type { BrandPreset, @@ -52,8 +55,11 @@ export type { PreviewHooks, PreviewSurface, PreviewTheme, + PrimaryActionConfig, + PrimaryActionContext, RecentMessage, SendAsUserStatus, + SendExtrasContext, SendPayload, SendResult, SupportedBlock, diff --git a/src/types.ts b/src/types.ts index 2d2d1ff..03c28e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { ActionsBlock, ContextBlock, @@ -486,6 +487,39 @@ export interface SendPayload { channelId: string; blocks: SupportedBlock[]; sendAsUser: boolean; + /** + * Values collected from the host's + * {@link BlockKitchenSendProps.renderSendExtras} fields, exactly as + * accumulated through {@link SendExtrasContext.setExtras}. Present + * (possibly `{}`) iff `renderSendExtras` is wired; absent otherwise, so + * existing consumers never see a new key. + */ + extras?: Record; +} + +/** + * Context passed to {@link BlockKitchenSendProps.renderSendExtras} on every + * render of the send dialog. The `extras` object is owned by the dialog — + * it resets each time the dialog opens (alongside the channel and identity + * pickers) and is delivered on {@link SendPayload.extras} when the user + * confirms. + */ +export interface SendExtrasContext { + /** + * Destination channel currently selected in the dialog, or `null` while + * channels are still loading / none is selected. Lets extras react to the + * destination (e.g. only offer cross-posting for some channels). + */ + channelId: string | null; + /** Whether "post as the user" (vs. the app bot) is currently selected. */ + sendAsUser: boolean; + /** The values collected so far. Render controlled inputs from these. */ + extras: Record; + /** + * Shallow-merges `patch` into {@link SendExtrasContext.extras}. Keys you + * never set simply don't appear in the payload. + */ + setExtras: (patch: Record) => void; } /** @@ -517,6 +551,49 @@ export interface ValidationSummary { errors: readonly string[]; } +/** + * Context handed to {@link PrimaryActionConfig.onClick}: everything a + * host-owned commitment step needs, captured at click time. + */ +export interface PrimaryActionContext { + /** + * The current draft in the builder's native format — the same shape + * `onChange` reports and `initialBlocks` accepts. Persist this to re-open + * the draft later; run it through `toSlackBlocks` when you need the + * Slack-ready payload. + */ + blocks: SupportedBlock[]; + /** + * The validation verdict at click time — the same summary + * {@link BlockKitchenBaseProps.onValidationChange} reports. + */ + validation: ValidationSummary; +} + +/** + * A host-owned primary action for compose-only mode, rendered in the + * toolbar slot where the built-in "Review & send" button normally sits. + * Use it when the builder's commitment step belongs to your app — e.g. a + * message drafter whose CTA is "Save template" — but you still want the + * button inside the builder chrome. For a CTA outside the builder, skip + * this and use {@link BlockKitchenBaseProps.onChange} + + * {@link BlockKitchenBaseProps.onValidationChange} instead. + */ +export interface PrimaryActionConfig { + /** Label + accessible name for the toolbar button. */ + label: string; + /** Called on click with the current draft and validation verdict. */ + onClick: (context: PrimaryActionContext) => void; + /** + * Disable the button while the draft has validation errors, mirroring the + * built-in Send gating. Defaults to `false`: a drafter typically allows + * committing a work-in-progress draft and surfaces the verdict in its own + * flow instead. The button is never disabled for an empty draft — gate on + * `context.blocks.length` in `onClick` if you need that. + */ + disableWhenInvalid?: boolean; +} + /** * Which token can edit a loaded message, as computed by the host. * `chat.update` only edits a message authored by the calling token: @@ -988,6 +1065,21 @@ export interface BlockKitchenSendProps { * behavior. The user-token path reuses {@link BlockKitchenSendProps.loadSendAsUserStatus}. */ editing?: EditingConfig; + /** + * Renders host-defined fields inside the built-in send dialog, below the + * channel and identity pickers. Values collected via + * {@link SendExtrasContext.setExtras} are delivered on + * {@link SendPayload.extras} when the user confirms. Use this when the + * built-in flow is almost right and you need a few extra controls (a + * cross-post toggle, a custom sender name) without rebuilding the dialog. + */ + renderSendExtras?: (context: SendExtrasContext) => ReactNode; + /** + * Unavailable with the send integration: the built-in Send/Update flow + * owns the toolbar's primary action. A host-owned + * {@link PrimaryActionConfig} exists only in compose-only mode. + */ + primaryAction?: undefined; } /** @@ -1008,6 +1100,16 @@ export interface BlockKitchenComposeOnlyProps { onSend?: undefined; /** Edit mode depends on the send integration, so it is unavailable too. */ editing?: undefined; + /** Extends the built-in send dialog, which doesn't exist in this mode. */ + renderSendExtras?: undefined; + /** + * Optional host-owned button rendered in the toolbar slot where the + * built-in "Review & send" normally sits — e.g. "Save template" for a + * message drafter. `onClick` receives the current draft and validation + * verdict ({@link PrimaryActionContext}). Omit it to render no primary + * action at all and place your CTA outside the builder. + */ + primaryAction?: PrimaryActionConfig; } /** diff --git a/test/block-kitchen-compose-only.test.tsx b/test/block-kitchen-compose-only.test.tsx index baaaa8b..81153e9 100644 --- a/test/block-kitchen-compose-only.test.tsx +++ b/test/block-kitchen-compose-only.test.tsx @@ -1,7 +1,7 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { afterEach, expect, it, vi } from 'vitest'; import { BlockKitchen } from '../src/components/block-kitchen'; -import type { BlockKitchenProps, SupportedBlock, ValidationSummary } from '../src/types'; +import type { BlockKitchenProps, PrimaryActionContext, SupportedBlock, ValidationSummary } from '../src/types'; const sendProps = { loadChannels: async () => [{ id: 'C1', name: 'general' }], @@ -53,6 +53,69 @@ it('warns on partial send wiring and hides the send button', () => { expect(screen.queryByRole('button', { name: /review & send/i })).toBeNull(); }); +it('renders the host primaryAction in the toolbar and passes draft + verdict on click', () => { + const onClick = vi.fn<(context: PrimaryActionContext) => void>(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Save template' })); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith({ + blocks: [{ type: 'divider' }], + validation: { valid: true, errorCount: 0, errors: [] } + }); +}); + +it('keeps primaryAction enabled for an invalid draft by default', () => { + const onClick = vi.fn<(context: PrimaryActionContext) => void>(); + // A bare section is invalid Block Kit; a drafter still allows committing it. + render( + + ); + const button = screen.getByRole('button', { name: 'Save template' }) as HTMLButtonElement; + expect(button.disabled).toBe(false); + fireEvent.click(button); + const context = onClick.mock.calls[0][0]; + expect(context.validation.valid).toBe(false); + expect(context.validation.errorCount).toBeGreaterThan(0); +}); + +it('disables primaryAction on validation errors when disableWhenInvalid is set', () => { + render( + {}, disableWhenInvalid: true }} + /> + ); + const button = screen.getByRole('button', { name: 'Save template' }) as HTMLButtonElement; + expect(button.disabled).toBe(true); +}); + +it('warns and ignores `primaryAction` when the send trio is wired', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const conflicting = { + ...sendProps, + primaryAction: { label: 'Save template', onClick: () => {} } + } as unknown as BlockKitchenProps; + render(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/`primaryAction` is only available in compose-only/)); + expect(screen.queryByRole('button', { name: 'Save template' })).toBeNull(); + expect(screen.getByRole('button', { name: /review & send/i })).toBeTruthy(); +}); + +it('warns and ignores `renderSendExtras` when the send trio is absent', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extrasOnly = { renderSendExtras: () => null } as unknown as BlockKitchenProps; + render(); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/`renderSendExtras` extends the built-in send dialog/)); +}); + it('warns and ignores `editing` when the send trio is absent', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const editingOnly = { @@ -66,9 +129,9 @@ it('warns and ignores `editing` when the send trio is absent', () => { expect(screen.queryByRole('button', { name: /find message/i })).toBeNull(); }); -// Type-level guarantees: the send trio is all-or-nothing, and `editing` -// requires it. These lines are enforced by `pnpm typecheck` (tsc runs over -// the test tsconfig), not by vitest. +// Type-level guarantees: the send trio is all-or-nothing; `editing` and +// `renderSendExtras` require it; `primaryAction` excludes it. These lines are +// enforced by `pnpm typecheck` (tsc runs over the test tsconfig), not vitest. // @ts-expect-error partial send wiring is rejected — the trio is all-or-nothing const partialWiring: BlockKitchenProps = { loadChannels: sendProps.loadChannels }; // @ts-expect-error `editing` is unavailable without the send trio @@ -78,5 +141,14 @@ const editingWithoutSend: BlockKitchenProps = { onUpdate: async () => ({ ok: true }) } }; +// @ts-expect-error `renderSendExtras` extends the send dialog, so it requires the send trio +const extrasWithoutSend: BlockKitchenProps = { renderSendExtras: () => null }; +// @ts-expect-error `primaryAction` is unavailable alongside the send trio +const primaryActionWithSend: BlockKitchenProps = { + ...sendProps, + primaryAction: { label: 'Save template', onClick: () => {} } +}; void partialWiring; void editingWithoutSend; +void extrasWithoutSend; +void primaryActionWithSend; diff --git a/test/public-api.test.ts b/test/public-api.test.ts index 25a8063..8c8e2dd 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -1,4 +1,5 @@ import { validateBlockKit } from '@tightknitai/slack-block-kit-validator'; +import { BlockKitchen, SendDialog, SlackSignInButton, TemplatePicker, useSlackSignIn } from '../src/index'; import { defaultPalette, extraAlertVariant, legacyInputVariants } from '../src/lib/default-blocks'; import { toSlackBlocks } from '../src/lib/to-slack-blocks'; import { decodeBlocksFromString, encodeBlocksToString } from '../src/lib/url-state'; @@ -169,3 +170,16 @@ describe('palette factories', () => { } }); }); + +describe('package entry point', () => { + // The send-flow primitives are public API for hosts building a bespoke + // send UI on top of compose-only mode; a rename or dropped export is a + // breaking change that should fail here, not in a consumer's build. + it('exports the components and send-flow primitives', () => { + expect(typeof BlockKitchen).toBe('function'); + expect(typeof TemplatePicker).toBe('function'); + expect(typeof SendDialog).toBe('function'); + expect(typeof useSlackSignIn).toBe('function'); + expect(typeof SlackSignInButton).toBe('function'); + }); +}); diff --git a/test/send-dialog-extras.test.tsx b/test/send-dialog-extras.test.tsx new file mode 100644 index 0000000..1efb294 --- /dev/null +++ b/test/send-dialog-extras.test.tsx @@ -0,0 +1,99 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, it, vi } from 'vitest'; +import { SendDialog, type SendDialogProps } from '../src/components/send-dialog'; +import type { SendExtrasContext, SendPayload, SupportedBlock } from '../src/types'; + +const BLOCKS = [{ type: 'divider' }] as SupportedBlock[]; + +const baseProps = { + open: true, + onOpenChange: () => {}, + blocks: BLOCKS, + loadChannels: async () => [{ id: 'C1', name: 'general' }], + loadSendAsUserStatus: async () => ({ canSendAsUser: false, oauthUrl: 'https://example.com/oauth' }), + onSend: async () => ({ ok: true }), + errorCount: 0 +} satisfies SendDialogProps; + +afterEach(() => vi.restoreAllMocks()); + +it('renders host extras inside the dialog with the current channel + identity', async () => { + render( + ( +
{`${channelId}:${sendAsUser}`}
+ )} + /> + ); + // The context reports "nothing selected yet" until the channels load. + expect(screen.getByTestId('extras-context').textContent).toBe('null:false'); + await act(async () => {}); // flush channel + status loads + expect(screen.getByTestId('extras-context').textContent).toBe('C1:false'); +}); + +it('delivers values collected via setExtras on payload.extras', async () => { + const onSend = vi.fn(async (_payload: SendPayload) => ({ ok: true })); + render( + ( + + )} + /> + ); + await act(async () => {}); + fireEvent.click(screen.getByRole('checkbox')); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await act(async () => {}); + expect(onSend).toHaveBeenCalledWith({ + channelId: 'C1', + blocks: BLOCKS, + sendAsUser: false, + extras: { crossPost: true } + }); +}); + +it('sends `extras: {}` when the slot is wired but never collects anything', async () => { + const onSend = vi.fn(async (_payload: SendPayload) => ({ ok: true })); + render(
No inputs here
} />); + await act(async () => {}); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await act(async () => {}); + expect(onSend.mock.calls[0][0].extras).toEqual({}); +}); + +it('omits `extras` from the payload when the slot is not wired', async () => { + const onSend = vi.fn(async (_payload: SendPayload) => ({ ok: true })); + render(); + await act(async () => {}); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await act(async () => {}); + expect(onSend).toHaveBeenCalledTimes(1); + expect(onSend.mock.calls[0][0]).not.toHaveProperty('extras'); +}); + +it('resets extras each time the dialog opens', async () => { + const renderSendExtras = ({ extras, setExtras }: SendExtrasContext) => ( + + ); + const { rerender } = render(); + await act(async () => {}); + fireEvent.click(screen.getByTestId('collect')); + expect(screen.getByTestId('collect').textContent).toBe('{"note":"keep"}'); + + rerender(); + rerender(); + await act(async () => {}); + expect(screen.getByTestId('collect').textContent).toBe('{}'); +});