diff --git a/README.md b/README.md index 34fb701..393f29e 100644 --- a/README.md +++ b/README.md @@ -105,9 +105,12 @@ 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, 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'`. | @@ -127,6 +130,163 @@ 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` 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) + +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. + +### 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 @@ -225,9 +385,9 @@ 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: +Helpers and send-flow primitives also exported: ```ts import { @@ -235,18 +395,29 @@ 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 { SupportedBlock, SupportedBlockType, BlockKitchenProps, + BlockKitchenBaseProps, // shared props + BlockKitchenSendProps, // the send trio + `editing` + `renderSendExtras` + BlockKitchenComposeOnlyProps, // the trio explicitly absent + `primaryAction` PaletteSection, PaletteVariant, SendPayload, SendResult, + SendDialogProps, + SendExtrasContext, + PrimaryActionConfig, + PrimaryActionContext, 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..2296367 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,15 @@ 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, + BlockKitchenComposeOnlyProps, + BlockKitchenProps, + BlockKitchenSendProps, + PreviewSurface, + PreviewTheme, + ValidationSummary +} from '../types'; import { BrandThemeScope } from './brand-theme-scope'; import { IssuesSheet } from './issues-sheet'; import { JsonDrawer } from './json-drawer'; @@ -48,12 +56,15 @@ export function BlockKitchen(props: BlockKitchenProps) { workspaceName, initialBlocks, onChange, + onValidationChange, previewHooks, customEmojis, loadChannels, loadSendAsUserStatus, onSend, + renderSendExtras, editing, + primaryAction, loadButtonLabel, updateButtonLabel, confirmUpdateLabel, @@ -70,7 +81,16 @@ export function BlockKitchen(props: BlockKitchenProps) { sendButtonLabel, confirmSendLabel, theme - } = props; + // 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; @@ -99,18 +119,54 @@ 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`.' + ); + } + 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 } = @@ -138,11 +194,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 +233,30 @@ 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 + // last-notified key rather than used as change signals themselves. + const lastNotifiedValidationRef = useRef(null); + useEffect(() => { + if (!onValidationChange) { + return; + } + const key = JSON.stringify([validationSummary.valid, validationSummary.errorCount, ...validationSummary.errors]); + if (lastNotifiedValidationRef.current === key) { + return; + } + lastNotifiedValidationRef.current = key; + 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 // 4px distance threshold so mouse clicks still open the editor cleanly. @@ -310,8 +390,21 @@ export function BlockKitchen(props: BlockKitchenProps) { // host app fully owns the theme; otherwise honor the prop. 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={!!editing} + editingEnabled={!!editingConfig} editBadge={ activeEditTarget ? { @@ -407,29 +500,33 @@ 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 +535,12 @@ export function BlockKitchen(props: BlockKitchenProps) { }} /> ) : null} - {editing ? ( + {editingConfig && loadChannels ? ( { replaceAll(result.blocks); 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 ? ( - {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 b421125..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, @@ -15,7 +18,10 @@ export { export type { AlertBlock, AlertLevel, + BlockKitchenBaseProps, + BlockKitchenComposeOnlyProps, BlockKitchenProps, + BlockKitchenSendProps, BuilderBlock, CardBlock, CarouselBlock, @@ -49,8 +55,11 @@ export type { PreviewHooks, PreviewSurface, PreviewTheme, + PrimaryActionConfig, + PrimaryActionContext, RecentMessage, SendAsUserStatus, + SendExtrasContext, SendPayload, SendResult, SupportedBlock, @@ -65,5 +74,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..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; } /** @@ -497,6 +531,69 @@ 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[]; +} + +/** + * 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: @@ -723,11 +820,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 +843,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 +869,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 +1035,85 @@ 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; + /** + * 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; +} + +/** + * 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; + /** 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; +} + +/** + * 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..81153e9 --- /dev/null +++ b/test/block-kitchen-compose-only.test.tsx @@ -0,0 +1,154 @@ +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, PrimaryActionContext, 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('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 = { + 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; `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 +const editingWithoutSend: BlockKitchenProps = { + editing: { + onLoadMessage: async () => ({ ok: false, reason: 'n/a' }), + 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..6f49320 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'; @@ -57,15 +58,12 @@ describe('url-state', () => { expect(encodeBlocksToString([])).toBe(''); }); - it.each([ - null, - undefined, - '', - 'not-base64!!!', - 'eyJub3QiOiJhbiBhcnJheSJ9' - ])('decodes invalid input %p to null', (input) => { - expect(decodeBlocksFromString(input)).toBeNull(); - }); + it.each([null, undefined, '', 'not-base64!!!', 'eyJub3QiOiJhbiBhcnJheSJ9'])( + 'decodes invalid input %p to null', + (input) => { + expect(decodeBlocksFromString(input)).toBeNull(); + } + ); it('preserves multibyte (utf-8) text through the roundtrip', () => { const blocks: SupportedBlock[] = [ @@ -169,3 +167,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/rich-text-editor-config.test.ts b/test/rich-text-editor-config.test.ts index ca3e1b3..adf73fe 100644 --- a/test/rich-text-editor-config.test.ts +++ b/test/rich-text-editor-config.test.ts @@ -105,19 +105,15 @@ describe('rich-text editor config: input rules (ENG-4850 #2)', () => { describe('rich-text editor config: autolink (ENG-4850 #1)', () => { // The autolinker fires when a space is typed after a URL-like token, so // we type the token followed by a space (char-by-char) to exercise it. - it.each([ - '2.xyz', - 'report.zip', - 'logo.png', - 'example.com', - 'www.example.com', - 'v2.api' - ])('does NOT auto-link bare host-like token %p', (token) => { - const editor = makeEditor(); - typeText(editor, `${token} `); - expect(hasMark(editor, 'link')).toBe(false); - editor.destroy(); - }); + it.each(['2.xyz', 'report.zip', 'logo.png', 'example.com', 'www.example.com', 'v2.api'])( + 'does NOT auto-link bare host-like token %p', + (token) => { + const editor = makeEditor(); + typeText(editor, `${token} `); + expect(hasMark(editor, 'link')).toBe(false); + editor.destroy(); + } + ); it('DOES auto-link a URL typed with an explicit https scheme', () => { const editor = makeEditor(); diff --git a/test/rich-text-tiptap-linebreaks.test.ts b/test/rich-text-tiptap-linebreaks.test.ts index 2ff3661..3a31206 100644 --- a/test/rich-text-tiptap-linebreaks.test.ts +++ b/test/rich-text-tiptap-linebreaks.test.ts @@ -65,17 +65,14 @@ describe('rich-text-tiptap hard breaks (Shift+Enter soft line breaks)', () => { ]); }); - it.each([ - 'a\nb', - 'a\n\nb', - 'line1\nline2\nline3', - '\nleading', - 'trailing\n' - ])('round-trips a Slack text run with newlines: %p', (text) => { - const block = sectionBlock({ type: 'text', text }); - const out = proseMirrorToRichText(richTextToProseMirror(block)); - expect(firstSectionElements(out)).toEqual([{ type: 'text', text }]); - }); + it.each(['a\nb', 'a\n\nb', 'line1\nline2\nline3', '\nleading', 'trailing\n'])( + 'round-trips a Slack text run with newlines: %p', + (text) => { + const block = sectionBlock({ type: 'text', text }); + const out = proseMirrorToRichText(richTextToProseMirror(block)); + expect(firstSectionElements(out)).toEqual([{ type: 'text', text }]); + } + ); it('round-trips a hardBreak inside a list item', () => { const block = { 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('{}'); +});