From 7c3115efd5ec22fa3e4853034745afbf6896d1d3 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Sat, 16 May 2026 12:59:26 -0400 Subject: [PATCH 1/2] feat(templates): ship richer default template gallery as library export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `defaultTemplates` — a curated set of 10 polished templates covering all three Slack surfaces (message / modal / app_home) and exercising every supported block type (section, header, divider, context, actions, image, markdown, rich_text, table, alert, card, carousel, context_actions, input) plus the full element catalog (every select type, all date/time pickers, all text-input variants, file input, rich text input, feedback / icon buttons, image accessories, overflow menus, button confirm dialogs). Previously the live demo and Storybook each maintained their own short, 3-block sample arrays (header + section + divider only) — useful for a hello-world but did not show what the platform can render. Both now import `defaultTemplates` from the package so the demo, Storybook, and downstream consumers share a single polished gallery. Template lineup (grouped by category): - Engineering: Pull request review, Incident report - Approvals: Expense approval, Confirm destructive action - Team: Daily standup - Announcements: Product release - Forms: Customer feedback intake - Scheduling: Schedule meeting - Home tabs: Welcome / onboarding, Team dashboard Also adds public-API tests for the new export: id uniqueness, non-empty blocks, valid surface enum, surface-compatibility (mirroring the validator's forbidden-blocks-per-surface rules), and `toSlackBlocks` roundtrip. Co-Authored-By: Claude Opus 4.7 (1M context) --- demo/src/App.tsx | 78 +- src/components/template-picker.stories.tsx | 130 +- src/index.ts | 1 + src/lib/default-templates.ts | 1340 ++++++++++++++++++++ test/public-api.test.ts | 60 +- 5 files changed, 1411 insertions(+), 198 deletions(-) create mode 100644 src/lib/default-templates.ts diff --git a/demo/src/App.tsx b/demo/src/App.tsx index a6ffc14..dce14fe 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -1,6 +1,7 @@ import { BlockKitchen, type ChannelOption, + defaultTemplates, type SendAsUserStatus, type SendPayload, type SendResult, @@ -33,81 +34,6 @@ const INITIAL_BLOCKS: SupportedBlock[] = [ { type: 'divider' } ]; -const TEMPLATES: Template[] = [ - { - id: 'approval-request', - name: 'Approval request', - description: 'Header + body + approve/reject actions.', - category: 'Approvals', - surface: 'message', - blocks: [ - { type: 'header', text: { type: 'plain_text', text: 'Approval needed' } }, - { - type: 'section', - text: { type: 'mrkdwn', text: '*Sarah* requested time off from *Mar 12* to *Mar 18*.' } - }, - { - type: 'actions', - elements: [ - { - type: 'button', - text: { type: 'plain_text', text: 'Approve' }, - style: 'primary', - value: 'approve' - }, - { - type: 'button', - text: { type: 'plain_text', text: 'Reject' }, - style: 'danger', - value: 'reject' - } - ] - } - ] - }, - { - id: 'product-release', - name: 'Product release', - description: 'Announce a new release with a CTA.', - category: 'Notifications', - surface: 'message', - blocks: [ - { type: 'header', text: { type: 'plain_text', text: 'We just shipped v2.5' } }, - { - type: 'section', - text: { type: 'mrkdwn', text: 'New: bulk edit, keyboard shortcuts, and a redesigned inbox.' } - } - ] - }, - { - id: 'daily-standup', - name: 'Daily standup', - description: 'Yesterday / Today / Blockers prompts.', - category: 'Polls and surveys', - surface: 'message', - blocks: [ - { type: 'header', text: { type: 'plain_text', text: 'Daily standup' } }, - { type: 'section', text: { type: 'mrkdwn', text: '*Yesterday:* ...' } }, - { type: 'section', text: { type: 'mrkdwn', text: '*Today:* ...' } }, - { type: 'section', text: { type: 'mrkdwn', text: '*Blockers:* ...' } } - ] - }, - { - id: 'modal-confirm', - name: 'Confirm delete', - description: 'Modal confirmation before a destructive action.', - category: 'Approvals', - surface: 'modal', - blocks: [ - { type: 'header', text: { type: 'plain_text', text: 'Are you sure?' } }, - { - type: 'section', - text: { type: 'mrkdwn', text: 'This action cannot be undone.' } - } - ] - } -]; - async function loadChannels(): Promise { await new Promise((r) => setTimeout(r, 200)); return MOCK_CHANNELS; @@ -238,7 +164,7 @@ export function App() { }} > t.surface !== 'app_home') } }; @@ -177,9 +65,9 @@ export const ClickCardInvokesHandler: Story = { args: { surface: 'message' }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - const approval = await canvas.findByRole('button', { name: /approval request/i }); - await userEvent.click(approval); + const card = await canvas.findByRole('button', { name: /expense approval/i }); + await userEvent.click(card); await expect(args.onSelect).toHaveBeenCalledOnce(); - await expect(args.onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'approval-request' })); + await expect(args.onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'expense-approval' })); } }; diff --git a/src/index.ts b/src/index.ts index 7d5e460..de74640 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export type { } from './lib/brand-theme'; export type { PaletteSection, PaletteVariant } from './lib/default-blocks'; export { defaultPalette, extraAlertVariant, legacyInputVariants } from './lib/default-blocks'; +export { defaultTemplates, TEMPLATE_CATEGORIES } from './lib/default-templates'; export { toSlackBlocks } from './lib/to-slack-blocks'; export { decodeBlocksFromString, diff --git a/src/lib/default-templates.ts b/src/lib/default-templates.ts new file mode 100644 index 0000000..74b33e2 --- /dev/null +++ b/src/lib/default-templates.ts @@ -0,0 +1,1340 @@ +import type { SupportedBlock, Template } from '../types'; + +/** + * A curated showcase set of {@link Template}s consumed by the live demo, + * Storybook fixtures, and any downstream app that wants a polished + * starting gallery. The set is intentionally broad — between them the + * templates exercise every supported block type and a wide slice of the + * element catalog (every select type, all date/time pickers, every + * text-input variant, file input, rich text input, feedback / icon + * buttons, image accessories, overflow menus, button confirm dialogs). + * + * Each template's `surface` is the surface its blocks were validated + * against; the runtime validator enforces surface compatibility (e.g. + * `alert` is modal-only, `table` / `markdown` / `carousel` / + * `context_actions` are forbidden on modals, `table` / `markdown` / + * `context_actions` are forbidden on app-home tabs), and the templates + * here respect those rules. + */ + +/** + * Categories used to group templates in the {@link TemplatePicker}. + * Exposed as a named constant so consumers can reference the same + * strings when extending the set. + */ +export const TEMPLATE_CATEGORIES = { + engineering: 'Engineering', + approvals: 'Approvals', + team: 'Team', + announcements: 'Announcements', + forms: 'Forms', + scheduling: 'Scheduling', + homeTabs: 'Home tabs' +} as const; + +// --- Template 1: Pull request review (message) ----------------------------- + +const PR_REVIEW_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Pull request ready for review', emoji: true } + }, + { + type: 'context', + elements: [ + { + type: 'image', + image_url: 'https://placehold.co/24x24/4f46e5/ffffff?text=AK', + alt_text: 'Avatar' + }, + { + type: 'mrkdwn', + text: '*Aisha Khan* opened in · 3 minutes ago' + } + ] + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '** \n_Backports the deterministic retry helper so duplicate `payment.succeeded` events resolve to a single ledger entry. Closes #471._' + }, + accessory: { + type: 'image', + image_url: 'https://placehold.co/64x64/0ea5e9/ffffff?text=PR', + alt_text: 'Repository icon' + } + }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_preformatted', + elements: [ + { + type: 'text', + text: '- async function handleWebhook(event) {\n- await ledger.insert(event);\n+ async function handleWebhook(event) {\n+ const key = idempotencyKey(event);\n+ await ledger.upsert(key, event);\n+ }' + } + ] + } + ] + }, + { + type: 'table', + column_settings: [{ align: 'left' }, { align: 'right' }, { align: 'right' }, { align: 'center' }], + rows: [ + [ + { type: 'raw_text', text: 'File' }, + { type: 'raw_text', text: 'Added' }, + { type: 'raw_text', text: 'Removed' }, + { type: 'raw_text', text: 'CI' } + ], + [ + { type: 'raw_text', text: 'src/webhooks/handler.ts' }, + { type: 'raw_text', text: '+42' }, + { type: 'raw_text', text: '−18' }, + { type: 'raw_text', text: '✅' } + ], + [ + { type: 'raw_text', text: 'src/lib/idempotency.ts' }, + { type: 'raw_text', text: '+96' }, + { type: 'raw_text', text: '−0' }, + { type: 'raw_text', text: '✅' } + ], + [ + { type: 'raw_text', text: 'test/webhooks.test.ts' }, + { type: 'raw_text', text: '+128' }, + { type: 'raw_text', text: '−4' }, + { type: 'raw_text', text: '✅' } + ] + ] + }, + { type: 'divider' }, + { + type: 'actions', + elements: [ + { + type: 'users_select', + action_id: 'pr_review_reviewer', + placeholder: { type: 'plain_text', text: 'Pick a reviewer', emoji: true } + }, + { + type: 'button', + action_id: 'pr_review_view', + text: { type: 'plain_text', text: 'View pull request', emoji: true }, + url: 'https://github.com/example/payments/pull/482', + style: 'primary' + }, + { + type: 'overflow', + action_id: 'pr_review_overflow', + options: [ + { + text: { type: 'plain_text', text: 'Mute thread', emoji: true }, + value: 'mute' + }, + { + text: { type: 'plain_text', text: 'Reassign', emoji: true }, + value: 'reassign' + }, + { + text: { type: 'plain_text', text: 'Copy link', emoji: true }, + value: 'copy_link' + } + ] + } + ] + }, + { + type: 'context_actions', + elements: [ + { + type: 'feedback_buttons', + action_id: 'pr_review_feedback', + positive_button: { + text: { type: 'plain_text', text: 'Looks good' }, + value: 'positive' + }, + negative_button: { + text: { type: 'plain_text', text: 'Needs work' }, + value: 'negative' + } + }, + { + type: 'icon_button', + action_id: 'pr_review_remove', + icon: 'trash', + text: { type: 'plain_text', text: 'Dismiss' } + } + ] + } +]; + +// --- Template 2: Incident report (message) --------------------------------- + +const INCIDENT_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'INC-2419 · Checkout latency spike', emoji: true } + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: '🔴 *SEV-2* · Investigating · Commander: <@U02INCCMDR> · Started 18 min ago' + } + ] + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Summary*\np95 checkout latency rose from 240ms to 3.1s starting at 14:02 UTC. The spike correlates with the rollout of `payments-svc@v412` to the `us-east-1` cell. Customer-facing impact is limited to the EU and US regions; cart submissions are succeeding after retry.' + } + }, + { + type: 'table', + column_settings: [{ align: 'left' }, { align: 'left' }, { align: 'left' }], + rows: [ + [ + { type: 'raw_text', text: 'Time (UTC)' }, + { type: 'raw_text', text: 'Event' }, + { type: 'raw_text', text: 'Owner' } + ], + [ + { type: 'raw_text', text: '14:02' }, + { type: 'raw_text', text: 'Latency alert fired on /checkout' }, + { type: 'raw_text', text: 'PagerDuty' } + ], + [ + { type: 'raw_text', text: '14:06' }, + { type: 'raw_text', text: 'Paged on-call, rolled war room' }, + { type: 'raw_text', text: 'Aisha' } + ], + [ + { type: 'raw_text', text: '14:11' }, + { type: 'raw_text', text: 'Identified payments-svc@v412 rollout' }, + { type: 'raw_text', text: 'Marcus' } + ], + [ + { type: 'raw_text', text: '14:18' }, + { type: 'raw_text', text: 'Initiated rollback to v411' }, + { type: 'raw_text', text: 'Marcus' } + ] + ] + }, + { type: 'divider' }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [{ type: 'text', text: 'Next steps', style: { bold: true } }] + }, + { + type: 'rich_text_list', + style: 'bullet', + elements: [ + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Watch ' }, + { type: 'text', text: 'rollback', style: { code: true } }, + { type: 'text', text: ' converge across all cells (ETA 15 min).' } + ] + }, + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Diff the canary metrics for ' }, + { type: 'text', text: 'v411', style: { code: true } }, + { type: 'text', text: ' vs ' }, + { type: 'text', text: 'v412', style: { code: true } }, + { type: 'text', text: ' on the dashboard.' } + ] + }, + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Open a ' }, + { type: 'text', text: 'post-incident review', style: { italic: true } }, + { type: 'text', text: ' ticket once mitigation is confirmed.' } + ] + } + ] + } + ] + }, + { + type: 'actions', + elements: [ + { + type: 'button', + action_id: 'incident_acknowledge', + text: { type: 'plain_text', text: 'Acknowledge', emoji: true }, + style: 'primary', + confirm: { + title: { type: 'plain_text', text: 'Acknowledge incident?' }, + text: { + type: 'plain_text', + text: 'You will be paged for status updates every 15 minutes until mitigation is confirmed.' + }, + confirm: { type: 'plain_text', text: 'Acknowledge' }, + deny: { type: 'plain_text', text: 'Cancel' } + } + }, + { + type: 'datepicker', + action_id: 'incident_eta', + placeholder: { type: 'plain_text', text: 'ETA to mitigation', emoji: true } + }, + { + type: 'static_select', + action_id: 'incident_severity', + placeholder: { type: 'plain_text', text: 'Change severity', emoji: true }, + initial_option: { + text: { type: 'plain_text', text: 'SEV-2', emoji: true }, + value: 'sev2' + }, + options: [ + { text: { type: 'plain_text', text: 'SEV-1', emoji: true }, value: 'sev1' }, + { text: { type: 'plain_text', text: 'SEV-2', emoji: true }, value: 'sev2' }, + { text: { type: 'plain_text', text: 'SEV-3', emoji: true }, value: 'sev3' }, + { text: { type: 'plain_text', text: 'SEV-4', emoji: true }, value: 'sev4' } + ] + } + ] + } +]; + +// --- Template 3: Expense approval (message) -------------------------------- + +const EXPENSE_APPROVAL_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Expense report awaiting your approval', emoji: true } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Submitted by:* <@U07SAVANNAH>\n*Trip:* Q1 customer offsite — Austin, TX\n*Submitted:* Mar 12, 2026 · Auto-categorized as *Travel & Entertainment*' + }, + accessory: { + type: 'image', + image_url: 'https://placehold.co/96x96/16a34a/ffffff?text=Receipt', + alt_text: 'Receipt thumbnail' + } + }, + { + type: 'table', + column_settings: [{ align: 'left' }, { align: 'left' }, { align: 'right' }], + rows: [ + [ + { type: 'raw_text', text: 'Item' }, + { type: 'raw_text', text: 'Vendor' }, + { type: 'raw_text', text: 'Amount' } + ], + [ + { type: 'raw_text', text: 'Flight (SFO→AUS)' }, + { type: 'raw_text', text: 'United' }, + { type: 'raw_text', text: '$418.20' } + ], + [ + { type: 'raw_text', text: 'Hotel (3 nights)' }, + { type: 'raw_text', text: 'Marriott' }, + { type: 'raw_text', text: '$612.00' } + ], + [ + { type: 'raw_text', text: 'Customer dinner' }, + { type: 'raw_text', text: 'Franklin BBQ' }, + { type: 'raw_text', text: '$184.50' } + ], + [ + { type: 'raw_text', text: 'Rideshare (4 trips)' }, + { type: 'raw_text', text: 'Uber' }, + { type: 'raw_text', text: '$87.30' } + ] + ] + }, + { type: 'divider' }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Total:* $1,302.00 USD\n*Policy:* Under T&E cap of $1,500 · ' + } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + action_id: 'expense_approve', + text: { type: 'plain_text', text: 'Approve', emoji: true }, + style: 'primary', + value: 'approve' + }, + { + type: 'button', + action_id: 'expense_reject', + text: { type: 'plain_text', text: 'Reject', emoji: true }, + style: 'danger', + value: 'reject', + confirm: { + title: { type: 'plain_text', text: 'Reject this expense?' }, + text: { + type: 'plain_text', + text: "The submitter will be notified and asked to revise. You'll be able to leave a comment on the next screen." + }, + confirm: { type: 'plain_text', text: 'Reject' }, + deny: { type: 'plain_text', text: 'Cancel' }, + style: 'danger' + } + }, + { + type: 'button', + action_id: 'expense_view_receipts', + text: { type: 'plain_text', text: 'View receipts', emoji: true }, + url: 'https://example.com/expenses/EX-9182/receipts' + } + ] + } +]; + +// --- Template 4: Daily standup (message) ----------------------------------- + +const STANDUP_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Daily standup — Platform team', emoji: true } + }, + { + type: 'context', + elements: [ + { + type: 'image', + image_url: 'https://placehold.co/24x24/f97316/ffffff?text=JD', + alt_text: 'Avatar' + }, + { type: 'mrkdwn', text: '*Jordan Diaz* · Tuesday, March 17, 2026' } + ] + }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [{ type: 'text', text: 'Yesterday', style: { bold: true } }] + }, + { + type: 'rich_text_list', + style: 'bullet', + elements: [ + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Shipped the retry helper behind ' }, + { type: 'text', text: 'payments_retry_v2', style: { code: true } }, + { type: 'text', text: ' (10% canary).' } + ] + }, + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Pair-debugged the flaky integration test with ' }, + { type: 'text', text: '@aisha', style: { bold: true } }, + { type: 'text', text: '.' } + ] + } + ] + }, + { + type: 'rich_text_section', + elements: [{ type: 'text', text: 'Today', style: { bold: true } }] + }, + { + type: 'rich_text_list', + style: 'bullet', + elements: [ + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Roll out ' }, + { type: 'text', text: 'payments_retry_v2', style: { code: true } }, + { type: 'text', text: ' to 50% if canary metrics stay healthy.' } + ] + }, + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Write the ' }, + { + type: 'link', + url: 'https://example.com/rfcs/idempotency-cache', + text: 'idempotency cache RFC' + }, + { type: 'text', text: '.' } + ] + } + ] + }, + { + type: 'rich_text_section', + elements: [{ type: 'text', text: 'Blockers', style: { bold: true } }] + }, + { + type: 'rich_text_list', + style: 'bullet', + elements: [ + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Need a review on ' }, + { + type: 'link', + url: 'https://github.com/example/payments/pull/482', + text: '#482' + }, + { type: 'text', text: ' before I can roll forward.' } + ] + } + ] + } + ] + }, + { type: 'divider' }, + { + type: 'actions', + elements: [ + { + type: 'datepicker', + action_id: 'standup_date', + placeholder: { type: 'plain_text', text: 'Pick a date', emoji: true } + }, + { + type: 'multi_users_select', + action_id: 'standup_blocked_by', + placeholder: { type: 'plain_text', text: 'Blocked by…', emoji: true } + } + ] + }, + { + type: 'context_actions', + elements: [ + { + type: 'feedback_buttons', + action_id: 'standup_feedback', + positive_button: { + text: { type: 'plain_text', text: '👍' }, + value: 'positive' + }, + negative_button: { + text: { type: 'plain_text', text: '👎' }, + value: 'negative' + } + } + ] + } +]; + +// --- Template 5: Product release (message) --------------------------------- + +const PRODUCT_RELEASE_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Acme v2.5 is live 🎉', emoji: true } + }, + { + type: 'image', + image_url: 'https://placehold.co/1200x420/6366f1/ffffff?text=Acme+v2.5', + alt_text: 'Acme v2.5 release banner', + title: { type: 'plain_text', text: 'What shipped this month', emoji: true } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: "The biggest release of the quarter — *bulk edit*, a redesigned inbox, and the new *Insights* panel. Here's the long-form rundown." + } + }, + { + type: 'markdown', + text: '## Highlights\n\n- **Bulk edit** — multi-select rows and apply changes inline (Cmd-click to extend).\n- **Inbox 2.0** — threaded conversations, snooze, and saved-views in the sidebar.\n- **Insights** — first-class dashboards for response time, escalations, and CSAT.\n\n## Compatibility\n\n| Surface | Supported | Notes |\n| --- | --- | --- |\n| Web | ✅ | Rolling out over 48h |\n| iOS 17+ | ✅ | App Store update required |\n| Desktop | ✅ | Auto-updates on next restart |\n| Slack app | ✅ | New `/acme inbox` command |\n\nUpgrade is automatic — no migration steps required.' + }, + { type: 'divider' }, + { + type: 'carousel', + elements: [ + { + type: 'card', + hero_image: { + type: 'image', + image_url: 'https://placehold.co/600x300/4f46e5/ffffff?text=Bulk+edit', + alt_text: 'Bulk edit screenshot' + }, + title: { type: 'mrkdwn', text: '*Bulk edit*' }, + body: { + type: 'mrkdwn', + text: 'Select dozens of rows and apply changes in one shot. Shift-click to extend a range.' + }, + actions: [ + { + type: 'button', + action_id: 'release_card_bulk_edit', + text: { type: 'plain_text', text: 'Read the guide', emoji: true }, + url: 'https://example.com/docs/bulk-edit' + } + ] + }, + { + type: 'card', + hero_image: { + type: 'image', + image_url: 'https://placehold.co/600x300/0ea5e9/ffffff?text=Inbox+2.0', + alt_text: 'Inbox 2.0 screenshot' + }, + title: { type: 'mrkdwn', text: '*Inbox 2.0*' }, + body: { + type: 'mrkdwn', + text: 'Threaded conversations, snooze, and saved-views — all in a redesigned sidebar.' + }, + actions: [ + { + type: 'button', + action_id: 'release_card_inbox', + text: { type: 'plain_text', text: 'Watch the demo', emoji: true }, + url: 'https://example.com/demos/inbox' + } + ] + }, + { + type: 'card', + hero_image: { + type: 'image', + image_url: 'https://placehold.co/600x300/16a34a/ffffff?text=Insights', + alt_text: 'Insights screenshot' + }, + title: { type: 'mrkdwn', text: '*Insights*' }, + body: { + type: 'mrkdwn', + text: 'First-class dashboards for response time, escalations, and CSAT — opt in from Settings → Beta.' + }, + actions: [ + { + type: 'button', + action_id: 'release_card_insights', + text: { type: 'plain_text', text: 'Open Insights', emoji: true }, + url: 'https://example.com/insights' + } + ] + } + ] + }, + { + type: 'context', + elements: [{ type: 'mrkdwn', text: '*Acme v2.5* · Released by <@U02PRODUCT> · March 17, 2026' }] + } +]; + +// --- Template 6: Customer feedback intake (modal) -------------------------- + +const FEEDBACK_INTAKE_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Send us feedback', emoji: true } + }, + { + type: 'alert', + level: 'info', + text: { + type: 'mrkdwn', + text: 'Thanks for taking the time — every field below is optional except *Subject* and *Category*. We read every submission.' + } + }, + { type: 'divider' }, + { + type: 'input', + block_id: 'feedback_subject', + label: { type: 'plain_text', text: 'Subject', emoji: true }, + element: { + type: 'plain_text_input', + action_id: 'feedback_subject_input', + placeholder: { + type: 'plain_text', + text: 'One sentence on what this is about', + emoji: true + }, + max_length: 120 + } + }, + { + type: 'input', + block_id: 'feedback_category', + label: { type: 'plain_text', text: 'Category', emoji: true }, + element: { + type: 'static_select', + action_id: 'feedback_category_select', + placeholder: { type: 'plain_text', text: 'Pick one', emoji: true }, + options: [ + { text: { type: 'plain_text', text: 'Bug report', emoji: true }, value: 'bug' }, + { + text: { type: 'plain_text', text: 'Feature request', emoji: true }, + value: 'feature' + }, + { + text: { type: 'plain_text', text: 'Question / how-do-I', emoji: true }, + value: 'question' + }, + { + text: { type: 'plain_text', text: 'Account / billing', emoji: true }, + value: 'billing' + }, + { text: { type: 'plain_text', text: 'Other', emoji: true }, value: 'other' } + ] + } + }, + { + type: 'input', + block_id: 'feedback_tags', + label: { type: 'plain_text', text: 'Tags', emoji: true }, + optional: true, + element: { + type: 'multi_static_select', + action_id: 'feedback_tags_select', + placeholder: { type: 'plain_text', text: 'Pick any that apply', emoji: true }, + max_selected_items: 5, + options: [ + { text: { type: 'plain_text', text: 'iOS', emoji: true }, value: 'ios' }, + { text: { type: 'plain_text', text: 'Android', emoji: true }, value: 'android' }, + { text: { type: 'plain_text', text: 'Web', emoji: true }, value: 'web' }, + { text: { type: 'plain_text', text: 'Desktop', emoji: true }, value: 'desktop' }, + { text: { type: 'plain_text', text: 'Slack app', emoji: true }, value: 'slack' }, + { text: { type: 'plain_text', text: 'API', emoji: true }, value: 'api' }, + { text: { type: 'plain_text', text: 'Billing', emoji: true }, value: 'billing' } + ] + } + }, + { + type: 'input', + block_id: 'feedback_severity', + label: { type: 'plain_text', text: 'How blocking is this?', emoji: true }, + element: { + type: 'radio_buttons', + action_id: 'feedback_severity_radio', + options: [ + { + text: { type: 'plain_text', text: 'Hard blocker — I cannot work', emoji: true }, + value: 'blocker' + }, + { + text: { type: 'plain_text', text: 'Annoying but I have a workaround', emoji: true }, + value: 'workaround' + }, + { + text: { type: 'plain_text', text: 'Nice to have', emoji: true }, + value: 'nice_to_have' + } + ] + } + }, + { + type: 'input', + block_id: 'feedback_areas', + label: { type: 'plain_text', text: 'Affected areas', emoji: true }, + optional: true, + element: { + type: 'checkboxes', + action_id: 'feedback_areas_check', + options: [ + { text: { type: 'plain_text', text: 'Inbox', emoji: true }, value: 'inbox' }, + { text: { type: 'plain_text', text: 'Insights', emoji: true }, value: 'insights' }, + { text: { type: 'plain_text', text: 'Bulk edit', emoji: true }, value: 'bulk' }, + { text: { type: 'plain_text', text: 'Notifications', emoji: true }, value: 'notif' } + ] + } + }, + { + type: 'input', + block_id: 'feedback_description', + label: { type: 'plain_text', text: 'Tell us more', emoji: true }, + optional: true, + element: { + type: 'rich_text_input', + action_id: 'feedback_description_rich', + placeholder: { + type: 'plain_text', + text: 'Steps to reproduce, screenshots, or context', + emoji: true + } + } + }, + { + type: 'input', + block_id: 'feedback_attachments', + label: { type: 'plain_text', text: 'Screenshots', emoji: true }, + optional: true, + element: { + type: 'file_input', + action_id: 'feedback_attachments_file', + max_files: 3 + } + }, + { + type: 'input', + block_id: 'feedback_contact', + label: { type: 'plain_text', text: 'Best email to reach you', emoji: true }, + optional: true, + element: { + type: 'email_text_input', + action_id: 'feedback_contact_email', + placeholder: { type: 'plain_text', text: 'name@example.com', emoji: true } + } + } +]; + +// --- Template 7: Schedule meeting (modal) ---------------------------------- + +const SCHEDULE_MEETING_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Schedule a meeting', emoji: true } + }, + { + type: 'alert', + level: 'info', + text: { + type: 'mrkdwn', + text: "Attendees will get a calendar invite and a heads-up in the channel you pick. Times are local to each attendee's calendar." + } + }, + { + type: 'input', + block_id: 'meeting_title', + label: { type: 'plain_text', text: 'Title', emoji: true }, + element: { + type: 'plain_text_input', + action_id: 'meeting_title_input', + placeholder: { + type: 'plain_text', + text: 'Q2 planning sync', + emoji: true + }, + max_length: 80 + } + }, + { + type: 'input', + block_id: 'meeting_attendees', + label: { type: 'plain_text', text: 'Attendees', emoji: true }, + element: { + type: 'multi_users_select', + action_id: 'meeting_attendees_select', + placeholder: { type: 'plain_text', text: 'Pick people', emoji: true }, + max_selected_items: 12 + } + }, + { + type: 'input', + block_id: 'meeting_date', + label: { type: 'plain_text', text: 'Date', emoji: true }, + element: { + type: 'datepicker', + action_id: 'meeting_date_picker' + } + }, + { + type: 'input', + block_id: 'meeting_time', + label: { type: 'plain_text', text: 'Start time', emoji: true }, + element: { + type: 'timepicker', + action_id: 'meeting_time_picker' + } + }, + { + type: 'input', + block_id: 'meeting_duration', + label: { type: 'plain_text', text: 'Duration (minutes)', emoji: true }, + element: { + type: 'number_input', + action_id: 'meeting_duration_input', + is_decimal_allowed: false, + min_value: '15', + max_value: '240', + initial_value: '30' + } + }, + { + type: 'input', + block_id: 'meeting_channel', + label: { type: 'plain_text', text: 'Notify channel', emoji: true }, + optional: true, + element: { + type: 'channels_select', + action_id: 'meeting_channel_select', + placeholder: { type: 'plain_text', text: 'Optional', emoji: true } + } + }, + { + type: 'input', + block_id: 'meeting_link', + label: { type: 'plain_text', text: 'Video link', emoji: true }, + optional: true, + element: { + type: 'url_text_input', + action_id: 'meeting_link_input', + placeholder: { + type: 'plain_text', + text: 'https://meet.example.com/...', + emoji: true + } + } + }, + { + type: 'input', + block_id: 'meeting_agenda', + label: { type: 'plain_text', text: 'Agenda', emoji: true }, + optional: true, + element: { + type: 'rich_text_input', + action_id: 'meeting_agenda_rich', + placeholder: { + type: 'plain_text', + text: 'A few bullets are plenty', + emoji: true + } + } + }, + { type: 'divider' }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: ':lock: Only the people you invite will see this meeting in their calendar.' + } + ] + } +]; + +// --- Template 8: Confirm destructive action (modal) ------------------------ + +const CONFIRM_DESTRUCTIVE_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Delete workspace', emoji: true } + }, + { + type: 'alert', + level: 'warning', + text: { + type: 'mrkdwn', + text: '*This action is permanent.* Deleting a workspace removes every channel, message, file, and integration. Exports and audit logs are kept for 30 days, then purged.' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'You are about to delete *Acme — Marketing*. To confirm, type the workspace name below exactly.' + } + }, + { + type: 'input', + block_id: 'confirm_workspace_name', + label: { type: 'plain_text', text: 'Type the workspace name', emoji: true }, + element: { + type: 'plain_text_input', + action_id: 'confirm_workspace_name_input', + placeholder: { type: 'plain_text', text: 'Acme — Marketing', emoji: true } + } + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: 'Need an export first? .' + } + ] + } +]; + +// --- Template 9: Welcome / onboarding (app_home) --------------------------- + +const WELCOME_HOME_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Welcome to Acme for Slack 👋', emoji: true } + }, + { + type: 'image', + image_url: 'https://placehold.co/1200x320/6366f1/ffffff?text=Welcome+to+Acme', + alt_text: 'Welcome hero illustration' + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: "*You're almost set up.* Knock out the three steps below and your team will start getting Acme updates right in Slack." + } + }, + { type: 'divider' }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [{ type: 'text', text: 'Getting started', style: { bold: true } }] + }, + { + type: 'rich_text_list', + style: 'ordered', + elements: [ + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Connect your Acme workspace ', style: { bold: true } }, + { type: 'text', text: '— takes about a minute.' } + ] + }, + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Pick a default channel ', style: { bold: true } }, + { type: 'text', text: 'for routine updates (you can change this anytime).' } + ] + }, + { + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Invite your team ', style: { bold: true } }, + { type: 'text', text: 'with ' }, + { type: 'text', text: '/acme invite', style: { code: true } }, + { type: 'text', text: '.' } + ] + } + ] + } + ] + }, + { type: 'divider' }, + { + type: 'carousel', + elements: [ + { + type: 'card', + hero_image: { + type: 'image', + image_url: 'https://placehold.co/600x300/4f46e5/ffffff?text=Tour', + alt_text: 'Tour cover' + }, + title: { type: 'mrkdwn', text: '*Take the 2-minute tour*' }, + body: { + type: 'mrkdwn', + text: 'The fastest way to see what Acme + Slack can do.' + }, + actions: [ + { + type: 'button', + action_id: 'home_card_tour', + text: { type: 'plain_text', text: 'Start tour', emoji: true }, + url: 'https://example.com/tour' + } + ] + }, + { + type: 'card', + hero_image: { + type: 'image', + image_url: 'https://placehold.co/600x300/0ea5e9/ffffff?text=Templates', + alt_text: 'Templates cover' + }, + title: { type: 'mrkdwn', text: '*Try a template*' }, + body: { + type: 'mrkdwn', + text: 'Approval flows, incident war rooms, weekly digests — drop-in ready.' + }, + actions: [ + { + type: 'button', + action_id: 'home_card_templates', + text: { type: 'plain_text', text: 'Browse templates', emoji: true }, + url: 'https://example.com/templates' + } + ] + }, + { + type: 'card', + hero_image: { + type: 'image', + image_url: 'https://placehold.co/600x300/16a34a/ffffff?text=API', + alt_text: 'API cover' + }, + title: { type: 'mrkdwn', text: '*Build your own*' }, + body: { + type: 'mrkdwn', + text: 'REST + webhooks for the bespoke flows. SDKs in TypeScript, Python, and Go.' + }, + actions: [ + { + type: 'button', + action_id: 'home_card_api', + text: { type: 'plain_text', text: 'Read docs', emoji: true }, + url: 'https://example.com/docs/api' + } + ] + } + ] + }, + { + type: 'actions', + elements: [ + { + type: 'button', + action_id: 'home_connect', + text: { type: 'plain_text', text: 'Connect Acme workspace', emoji: true }, + style: 'primary', + url: 'https://example.com/connect' + }, + { + type: 'button', + action_id: 'home_docs', + text: { type: 'plain_text', text: 'Read the docs', emoji: true }, + url: 'https://example.com/docs' + } + ] + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: 'Need a hand? Reply in <#C0HELP> or email .' + } + ] + } +]; + +// --- Template 10: Team dashboard (app_home) -------------------------------- + +const TEAM_DASHBOARD_BLOCKS: SupportedBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: 'Platform team — at a glance', emoji: true } + }, + { + type: 'context', + elements: [{ type: 'mrkdwn', text: 'Last updated *just now* · auto-refreshes every 5 min' }] + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*🟢 Healthy*\nAll 4 services within SLO · 12 open PRs · 1 active incident (SEV-3, monitoring)' + }, + accessory: { + type: 'image', + image_url: 'https://placehold.co/72x72/16a34a/ffffff?text=OK', + alt_text: 'Status indicator' + } + }, + { type: 'divider' }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Payments service*\n_p95 latency 220ms · error rate 0.04% · 3 PRs awaiting review_' + }, + accessory: { + type: 'button', + action_id: 'dash_open_payments', + text: { type: 'plain_text', text: 'Open', emoji: true }, + url: 'https://example.com/projects/payments' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Identity service*\n_p95 latency 87ms · error rate 0.01% · all PRs merged_' + }, + accessory: { + type: 'button', + action_id: 'dash_open_identity', + text: { type: 'plain_text', text: 'Open', emoji: true }, + url: 'https://example.com/projects/identity' + } + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: '*Webhook dispatcher*\n_p95 latency 412ms · error rate 0.18% · monitoring INC-2419_' + }, + accessory: { + type: 'overflow', + action_id: 'dash_webhook_overflow', + options: [ + { + text: { type: 'plain_text', text: 'Open project', emoji: true }, + value: 'open' + }, + { + text: { type: 'plain_text', text: 'View dashboards', emoji: true }, + value: 'dashboards' + }, + { + text: { type: 'plain_text', text: 'Page on-call', emoji: true }, + value: 'page' + } + ] + } + }, + { type: 'divider' }, + { + type: 'carousel', + elements: [ + { + type: 'card', + icon: { + type: 'image', + image_url: 'https://placehold.co/36x36/4f46e5/ffffff?text=PR', + alt_text: 'PR icon' + }, + title: { type: 'mrkdwn', text: '*PR #482* — Reconcile webhook retries' }, + subtitle: { type: 'mrkdwn', text: 'Opened by Aisha · 3m ago' }, + body: { + type: 'mrkdwn', + text: 'Needs review before the 50% rollout this afternoon.' + } + }, + { + type: 'card', + icon: { + type: 'image', + image_url: 'https://placehold.co/36x36/f97316/ffffff?text=!', + alt_text: 'Incident icon' + }, + title: { type: 'mrkdwn', text: '*INC-2419* — Checkout latency spike' }, + subtitle: { type: 'mrkdwn', text: 'SEV-2 · monitoring rollback' }, + body: { + type: 'mrkdwn', + text: 'Mitigation deployed at 14:18 UTC. Watching error budgets for the next 30 min.' + } + }, + { + type: 'card', + icon: { + type: 'image', + image_url: 'https://placehold.co/36x36/16a34a/ffffff?text=✓', + alt_text: 'Deploy icon' + }, + title: { type: 'mrkdwn', text: '*Release v412.1*' }, + subtitle: { type: 'mrkdwn', text: 'Deployed · all cells green' }, + body: { + type: 'mrkdwn', + text: 'Hotfix for the idempotency-key collision shipped to 100%.' + } + } + ] + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: 'View the · ' + } + ] + } +]; + +/** + * The full curated template gallery. Order here is the order that the + * {@link TemplatePicker} renders cards within each category section. + */ +export const defaultTemplates: readonly Template[] = [ + { + id: 'pull-request-review', + name: 'Pull request review', + description: 'Diff snippet, file table, reviewer assignment, and feedback buttons.', + category: TEMPLATE_CATEGORIES.engineering, + surface: 'message', + blocks: PR_REVIEW_BLOCKS + }, + { + id: 'incident-report', + name: 'Incident report', + description: 'War-room update with timeline, next steps, and severity controls.', + category: TEMPLATE_CATEGORIES.engineering, + surface: 'message', + blocks: INCIDENT_BLOCKS + }, + { + id: 'expense-approval', + name: 'Expense approval', + description: 'Itemized expense report with approve, reject (with confirm), and receipts.', + category: TEMPLATE_CATEGORIES.approvals, + surface: 'message', + blocks: EXPENSE_APPROVAL_BLOCKS + }, + { + id: 'daily-standup', + name: 'Daily standup', + description: 'Rich-text Yesterday / Today / Blockers with date and "blocked by" picker.', + category: TEMPLATE_CATEGORIES.team, + surface: 'message', + blocks: STANDUP_BLOCKS + }, + { + id: 'product-release', + name: 'Product release', + description: 'Hero image, markdown release notes, and a feature carousel.', + category: TEMPLATE_CATEGORIES.announcements, + surface: 'message', + blocks: PRODUCT_RELEASE_BLOCKS + }, + { + id: 'feedback-intake', + name: 'Customer feedback intake', + description: 'Eight-field intake form with attachments, tags, severity, and contact email.', + category: TEMPLATE_CATEGORIES.forms, + surface: 'modal', + blocks: FEEDBACK_INTAKE_BLOCKS + }, + { + id: 'schedule-meeting', + name: 'Schedule meeting', + description: 'Title, attendees, date / time / duration, notify channel, and rich-text agenda.', + category: TEMPLATE_CATEGORIES.scheduling, + surface: 'modal', + blocks: SCHEDULE_MEETING_BLOCKS + }, + { + id: 'confirm-destructive', + name: 'Confirm destructive action', + description: 'Warning alert with type-to-confirm guard for an irreversible action.', + category: TEMPLATE_CATEGORIES.approvals, + surface: 'modal', + blocks: CONFIRM_DESTRUCTIVE_BLOCKS + }, + { + id: 'home-welcome', + name: 'Welcome / onboarding', + description: 'App-home tab with hero, ordered checklist, resource carousel, and CTAs.', + category: TEMPLATE_CATEGORIES.homeTabs, + surface: 'app_home', + blocks: WELCOME_HOME_BLOCKS + }, + { + id: 'team-dashboard', + name: 'Team dashboard', + description: 'Status overview, per-service rows with button accessories, and a recent-items carousel.', + category: TEMPLATE_CATEGORIES.homeTabs, + surface: 'app_home', + blocks: TEAM_DASHBOARD_BLOCKS + } +] as const; diff --git a/test/public-api.test.ts b/test/public-api.test.ts index d650728..cf5881f 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -1,7 +1,8 @@ import { defaultPalette, extraAlertVariant, legacyInputVariants } from '../src/lib/default-blocks'; +import { defaultTemplates } from '../src/lib/default-templates'; import { toSlackBlocks } from '../src/lib/to-slack-blocks'; import { decodeBlocksFromString, encodeBlocksToString } from '../src/lib/url-state'; -import type { SupportedBlock } from '../src/types'; +import type { PreviewSurface, SupportedBlock } from '../src/types'; describe('toSlackBlocks', () => { it('strips the builder-only `level` field from header blocks', () => { @@ -141,3 +142,60 @@ describe('palette factories', () => { expect(extraAlertVariant.factory().type).toBe('alert'); }); }); + +describe('default templates', () => { + const ALLOWED_SURFACES: readonly PreviewSurface[] = ['message', 'modal', 'app_home']; + + // Mirrors the surface-compatibility rules enforced at runtime by + // `@tightknitai/slack-block-kit-validator`'s `checkSurfaceCompatibility` + // helper. Kept inline so the test doesn't pull in the validator package + // at unit-test time. + const FORBIDDEN_BLOCKS_BY_SURFACE: Record> = { + message: new Set(['alert', 'file']), + modal: new Set(['card', 'carousel', 'context_actions', 'file', 'markdown', 'plan', 'table', 'task_card']), + app_home: new Set(['alert', 'context_actions', 'file', 'markdown', 'plan', 'table', 'task_card']) + }; + + it('template ids are unique', () => { + const ids = defaultTemplates.map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('every template has at least one block', () => { + for (const template of defaultTemplates) { + expect(template.blocks.length).toBeGreaterThan(0); + } + }); + + it("every template's surface is a recognized PreviewSurface", () => { + for (const template of defaultTemplates) { + expect(ALLOWED_SURFACES).toContain(template.surface); + } + }); + + it('no template uses a block type forbidden on its declared surface', () => { + for (const template of defaultTemplates) { + const forbidden = FORBIDDEN_BLOCKS_BY_SURFACE[template.surface]; + for (let i = 0; i < template.blocks.length; i++) { + const block = template.blocks[i] as { type?: string; element?: { type?: string } }; + if (block.type && forbidden.has(block.type)) { + throw new Error( + `Template "${template.id}" (surface "${template.surface}") uses forbidden block type "${block.type}" at index ${i}` + ); + } + if (block.type === 'input' && block.element?.type === 'file_input' && template.surface !== 'modal') { + throw new Error( + `Template "${template.id}" (surface "${template.surface}") uses file_input outside a modal surface at index ${i}` + ); + } + } + } + }); + + it("every template's blocks roundtrip through toSlackBlocks", () => { + for (const template of defaultTemplates) { + const out = toSlackBlocks(template.blocks as SupportedBlock[]); + expect(out.length).toBe(template.blocks.length); + } + }); +}); From dfa3ae43049887d8347ac92269e13ba715d55324 Mon Sep 17 00:00:00 2001 From: Zach Hawtof Date: Sat, 16 May 2026 13:11:16 -0400 Subject: [PATCH 2/2] refactor(templates): move template gallery from library to demo config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: templates are use-case examples (an "Expense approval" is for an approvals product, a "Daily standup" is for a team product) and don't belong in the library bundle. Unlike `defaultPalette`, which is core builder functionality auto-applied to `` when no `palette` prop is passed, templates are something each consuming app configures for its own use case. - Move `src/lib/default-templates.ts` → `demo/src/templates.ts`, rename the export to `demoTemplates`, and reword the doc comment to make clear this is the demo's config (not a shipped default). - Drop the `defaultTemplates` + `TEMPLATE_CATEGORIES` exports from `src/index.ts`. The library no longer ships any templates. - `demo/src/App.tsx` now imports `demoTemplates` from `./templates`. - Revert `template-picker.stories.tsx` to inline lightweight fixtures — stories exist to exercise the picker UI (categories, surface filter, empty state, dark theme), not to be a marketing showcase. - Drop the `defaultTemplates` describe block from `public-api.test.ts` since templates are no longer part of the public API. The library ESM bundle drops from 266 KB → 228 KB and the typed dist from 26.75 KB → 25.19 KB as a result. Co-Authored-By: Claude Opus 4.7 (1M context) --- demo/src/App.tsx | 4 +- .../src/templates.ts | 43 +++--- src/components/template-picker.stories.tsx | 135 ++++++++++++++++-- src/index.ts | 1 - test/public-api.test.ts | 60 +------- 5 files changed, 152 insertions(+), 91 deletions(-) rename src/lib/default-templates.ts => demo/src/templates.ts (96%) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index dce14fe..3eec084 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -1,7 +1,6 @@ import { BlockKitchen, type ChannelOption, - defaultTemplates, type SendAsUserStatus, type SendPayload, type SendResult, @@ -10,6 +9,7 @@ import { TemplatePicker } from '@tightknitai/block-kitchen'; import { useEffect, useState } from 'react'; +import { demoTemplates } from './templates'; const MOCK_CHANNELS: ChannelOption[] = [ { id: 'C0001', name: 'general' }, @@ -164,7 +164,7 @@ export function App() { }} > `. * - * Each template's `surface` is the surface its blocks were validated - * against; the runtime validator enforces surface compatibility (e.g. - * `alert` is modal-only, `table` / `markdown` / `carousel` / - * `context_actions` are forbidden on modals, `table` / `markdown` / - * `context_actions` are forbidden on app-home tabs), and the templates - * here respect those rules. + * The set is intentionally broad to showcase the platform in the live demo + * — between them the templates exercise every supported block type and a + * wide slice of the element catalog (every select type, all date/time + * pickers, every text-input variant, file input, rich text input, feedback + * / icon buttons, image accessories, overflow menus, button confirm + * dialogs). + * + * Each template's `surface` is the surface its blocks were authored for; + * the runtime validator enforces surface compatibility (e.g. `alert` is + * modal-only, `table` / `markdown` / `carousel` / `context_actions` are + * forbidden on modals, `table` / `markdown` / `context_actions` are + * forbidden on app-home tabs), and the templates here respect those rules. */ /** - * Categories used to group templates in the {@link TemplatePicker}. - * Exposed as a named constant so consumers can reference the same - * strings when extending the set. + * Categories used to group templates in the demo's ``. */ -export const TEMPLATE_CATEGORIES = { +const TEMPLATE_CATEGORIES = { engineering: 'Engineering', approvals: 'Approvals', team: 'Team', @@ -1254,9 +1257,9 @@ const TEAM_DASHBOARD_BLOCKS: SupportedBlock[] = [ /** * The full curated template gallery. Order here is the order that the - * {@link TemplatePicker} renders cards within each category section. + * picker renders cards within each category section. */ -export const defaultTemplates: readonly Template[] = [ +export const demoTemplates: readonly Template[] = [ { id: 'pull-request-review', name: 'Pull request review', diff --git a/src/components/template-picker.stories.tsx b/src/components/template-picker.stories.tsx index 2a30d39..1dd3d4c 100644 --- a/src/components/template-picker.stories.tsx +++ b/src/components/template-picker.stories.tsx @@ -1,9 +1,130 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, fn, userEvent, within } from 'storybook/test'; -import { defaultTemplates } from '../lib/default-templates'; +import type { Template } from '../types'; import { TemplatePicker } from './template-picker'; -const SAMPLE_TEMPLATES = defaultTemplates; +// Inline fixtures: these are story-only and intentionally lightweight. The +// package does not ship templates — they are use-case examples that belong +// in the consuming app's config (see `demo/src/templates.ts` for a richer +// real-world set). These samples cover enough variety to exercise the +// picker's categorization, surface filtering, and empty state. +const SAMPLE_TEMPLATES: Template[] = [ + { + id: 'approval-request', + name: 'Approval request', + description: 'Header + body + approve/reject actions.', + category: 'Approvals', + surface: 'message', + blocks: [ + { type: 'header', text: { type: 'plain_text', text: 'Approval needed' } }, + { + type: 'section', + text: { type: 'mrkdwn', text: '*Sarah* requested time off from *Mar 12* to *Mar 18*.' } + }, + { + type: 'actions', + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: 'Approve' }, + style: 'primary', + value: 'approve' + }, + { + type: 'button', + text: { type: 'plain_text', text: 'Reject' }, + style: 'danger', + value: 'reject' + } + ] + } + ] + }, + { + id: 'expense-approval', + name: 'Expense approval', + description: 'Approve an expense report inline.', + category: 'Approvals', + surface: 'message', + blocks: [ + { type: 'header', text: { type: 'plain_text', text: 'Expense report' } }, + { + type: 'section', + text: { type: 'mrkdwn', text: '*Amount:* $1,240.00\n*Category:* Travel' } + }, + { type: 'divider' } + ] + }, + { + id: 'new-comment', + name: 'New comment', + description: 'Notify a channel about a new comment.', + category: 'Notifications', + surface: 'message', + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text: ':speech_balloon: *Alex* left a comment on *Project Kickoff*.' } + }, + { type: 'context', elements: [{ type: 'mrkdwn', text: '2 minutes ago' }] } + ] + }, + { + id: 'product-release', + name: 'Product release', + description: 'Announce a new release with a CTA.', + category: 'Notifications', + surface: 'message', + blocks: [ + { type: 'header', text: { type: 'plain_text', text: 'We just shipped v2.5' } }, + { + type: 'section', + text: { type: 'mrkdwn', text: 'New: bulk edit, keyboard shortcuts, and a redesigned inbox.' } + } + ] + }, + { + id: 'daily-standup', + name: 'Daily standup', + description: 'Yesterday / Today / Blockers prompts.', + category: 'Polls and surveys', + surface: 'message', + blocks: [ + { type: 'header', text: { type: 'plain_text', text: 'Daily standup' } }, + { type: 'section', text: { type: 'mrkdwn', text: '*Yesterday:* ...' } }, + { type: 'section', text: { type: 'mrkdwn', text: '*Today:* ...' } }, + { type: 'section', text: { type: 'mrkdwn', text: '*Blockers:* ...' } } + ] + }, + { + id: 'confirm-delete', + name: 'Confirm delete', + description: 'Modal confirmation before a destructive action.', + category: 'Approvals', + surface: 'modal', + blocks: [ + { type: 'header', text: { type: 'plain_text', text: 'Are you sure?' } }, + { + type: 'section', + text: { type: 'mrkdwn', text: 'This will permanently delete the workspace.' } + } + ] + }, + { + id: 'home-welcome', + name: 'Welcome', + description: 'App home tab welcome layout.', + surface: 'app_home', + blocks: [ + { type: 'header', text: { type: 'plain_text', text: 'Welcome' } }, + { type: 'divider' }, + { + type: 'section', + text: { type: 'mrkdwn', text: 'Your app home tab content goes here.' } + } + ] + } +]; const meta = { title: 'BlockKitchen/TemplatePicker', @@ -36,10 +157,6 @@ export const FilteredToModalSurface: Story = { args: { surface: 'modal' } }; -export const FilteredToAppHomeSurface: Story = { - args: { surface: 'app_home' } -}; - export const Empty: Story = { args: { surface: 'app_home', templates: SAMPLE_TEMPLATES.filter((t) => t.surface !== 'app_home') } }; @@ -65,9 +182,9 @@ export const ClickCardInvokesHandler: Story = { args: { surface: 'message' }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - const card = await canvas.findByRole('button', { name: /expense approval/i }); - await userEvent.click(card); + const approval = await canvas.findByRole('button', { name: /approval request/i }); + await userEvent.click(approval); await expect(args.onSelect).toHaveBeenCalledOnce(); - await expect(args.onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'expense-approval' })); + await expect(args.onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'approval-request' })); } }; diff --git a/src/index.ts b/src/index.ts index de74640..7d5e460 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,6 @@ export type { } from './lib/brand-theme'; export type { PaletteSection, PaletteVariant } from './lib/default-blocks'; export { defaultPalette, extraAlertVariant, legacyInputVariants } from './lib/default-blocks'; -export { defaultTemplates, TEMPLATE_CATEGORIES } from './lib/default-templates'; export { toSlackBlocks } from './lib/to-slack-blocks'; export { decodeBlocksFromString, diff --git a/test/public-api.test.ts b/test/public-api.test.ts index cf5881f..d650728 100644 --- a/test/public-api.test.ts +++ b/test/public-api.test.ts @@ -1,8 +1,7 @@ import { defaultPalette, extraAlertVariant, legacyInputVariants } from '../src/lib/default-blocks'; -import { defaultTemplates } from '../src/lib/default-templates'; import { toSlackBlocks } from '../src/lib/to-slack-blocks'; import { decodeBlocksFromString, encodeBlocksToString } from '../src/lib/url-state'; -import type { PreviewSurface, SupportedBlock } from '../src/types'; +import type { SupportedBlock } from '../src/types'; describe('toSlackBlocks', () => { it('strips the builder-only `level` field from header blocks', () => { @@ -142,60 +141,3 @@ describe('palette factories', () => { expect(extraAlertVariant.factory().type).toBe('alert'); }); }); - -describe('default templates', () => { - const ALLOWED_SURFACES: readonly PreviewSurface[] = ['message', 'modal', 'app_home']; - - // Mirrors the surface-compatibility rules enforced at runtime by - // `@tightknitai/slack-block-kit-validator`'s `checkSurfaceCompatibility` - // helper. Kept inline so the test doesn't pull in the validator package - // at unit-test time. - const FORBIDDEN_BLOCKS_BY_SURFACE: Record> = { - message: new Set(['alert', 'file']), - modal: new Set(['card', 'carousel', 'context_actions', 'file', 'markdown', 'plan', 'table', 'task_card']), - app_home: new Set(['alert', 'context_actions', 'file', 'markdown', 'plan', 'table', 'task_card']) - }; - - it('template ids are unique', () => { - const ids = defaultTemplates.map((t) => t.id); - expect(new Set(ids).size).toBe(ids.length); - }); - - it('every template has at least one block', () => { - for (const template of defaultTemplates) { - expect(template.blocks.length).toBeGreaterThan(0); - } - }); - - it("every template's surface is a recognized PreviewSurface", () => { - for (const template of defaultTemplates) { - expect(ALLOWED_SURFACES).toContain(template.surface); - } - }); - - it('no template uses a block type forbidden on its declared surface', () => { - for (const template of defaultTemplates) { - const forbidden = FORBIDDEN_BLOCKS_BY_SURFACE[template.surface]; - for (let i = 0; i < template.blocks.length; i++) { - const block = template.blocks[i] as { type?: string; element?: { type?: string } }; - if (block.type && forbidden.has(block.type)) { - throw new Error( - `Template "${template.id}" (surface "${template.surface}") uses forbidden block type "${block.type}" at index ${i}` - ); - } - if (block.type === 'input' && block.element?.type === 'file_input' && template.surface !== 'modal') { - throw new Error( - `Template "${template.id}" (surface "${template.surface}") uses file_input outside a modal surface at index ${i}` - ); - } - } - } - }); - - it("every template's blocks roundtrip through toSlackBlocks", () => { - for (const template of defaultTemplates) { - const out = toSlackBlocks(template.blocks as SupportedBlock[]); - expect(out.length).toBe(template.blocks.length); - } - }); -});