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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 176 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'`. |
Expand All @@ -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<BrandTokens>` 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<SupportedBlock[]>([]);
const [validation, setValidation] = useState<ValidationSummary | null>(null);

return (
<>
<BlockKitchen onChange={setDraft} onValidationChange={setValidation} />
{/* Your CTA, your copy, your placement — gated on the builder's own verdict. */}
<button
disabled={draft.length === 0 || validation?.valid === false}
onClick={() => onNext(toSlackBlocks(draft))}
>
Next: choose audience
</button>
</>
);
}
```

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
<BlockKitchen
primaryAction={{
label: "Save template",
onClick: ({ blocks, validation }) => 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
<BlockKitchen
loadChannels={loadChannels}
loadSendAsUserStatus={loadSendAsUserStatus}
renderSendExtras={({ extras, setExtras }) => (
<>
<label>
<input
type="checkbox"
checked={Boolean(extras.alsoPostToIntranet)}
onChange={(e) => setExtras({ alsoPostToIntranet: e.target.checked })}
/>
Also publish to the intranet
</label>
<label>
Custom sender name
<input
type="text"
value={String(extras.senderName ?? "")}
onChange={(e) => setExtras({ senderName: e.target.value })}
/>
</label>
</>
)}
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
Expand Down Expand Up @@ -225,28 +385,39 @@ 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 {
toSlackBlocks, // strips builder-only fields (e.g. header `level`) before sending
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";
```
Expand Down
9 changes: 7 additions & 2 deletions src/components/block-kitchen.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -45,7 +50,7 @@ const meta = {
</div>
)
]
} satisfies Meta<typeof BlockKitchen>;
} satisfies Meta<SendModeProps>;

export default meta;
type Story = StoryObj<typeof meta>;
Expand Down
Loading
Loading