diff --git a/apps/showcase-bot/config.yaml b/apps/showcase-bot/config.yaml index 454030c..3114a46 100644 --- a/apps/showcase-bot/config.yaml +++ b/apps/showcase-bot/config.yaml @@ -10,7 +10,7 @@ slack: commands: - command: /showcase description: Block Kit showcase commands - usage_hint: '[generate|clear|modal|help]' + usage_hint: '[generate|block-kit|clear|modal|help]' shortcuts: - callback_id: showcase_message_context name: Showcase message context diff --git a/apps/showcase-bot/package.json b/apps/showcase-bot/package.json index 30e7108..6288075 100644 --- a/apps/showcase-bot/package.json +++ b/apps/showcase-bot/package.json @@ -12,6 +12,7 @@ "test": "echo 'No tests yet'" }, "dependencies": { + "@botarium/block-kit": "workspace:*", "@slack/bolt": "^4.6.0", "@slack/types": "^2.15.0", "@slack/web-api": "^7.10.0", diff --git a/apps/showcase-bot/src/listeners/commands/showcase.ts b/apps/showcase-bot/src/listeners/commands/showcase.ts index c6b6ecb..5cdcfac 100644 --- a/apps/showcase-bot/src/listeners/commands/showcase.ts +++ b/apps/showcase-bot/src/listeners/commands/showcase.ts @@ -1,4 +1,23 @@ import type { App } from '@slack/bolt' +import type { Block } from '@slack/types' +import { + modal, + input, + textInput, + emailInput, + urlInput, + numberInput, + datePicker, + timePicker, + dateTimePicker, + radioButtons, + checkboxes, + staticSelect, + multiStaticSelect, + fileInput, + options, +} from '@botarium/block-kit' +import { blockKitMessages } from '../../messages/block-kit-messages' import { showcaseMessages } from '../../messages/showcase-messages' import { slackLogger } from '../../utils/logger' @@ -25,37 +44,51 @@ export async function clearShowcaseChannel(client: App['client']) { } } -/** - * Send all showcase messages to the #showcase channel. - * Clears existing messages first to prevent duplicates across restarts. - * Reusable: called both on startup (auto-populate) and via /showcase command. - */ -export async function sendShowcaseMessages(client: App['client']) { +async function postMessages( + client: App['client'], + messages: { text?: string; blocks: Block[] }[], + label: string +) { await clearShowcaseChannel(client) - for (const message of showcaseMessages) { + for (const message of messages) { try { await client.chat.postMessage({ channel: SHOWCASE_CHANNEL, - text: message.fallbackText, + text: message.text, blocks: message.blocks, }) } catch (err) { slackLogger.error( - { err, fallbackText: message.fallbackText }, - 'Failed to send showcase message' + { err, text: message.text }, + `Failed to send ${label} message` ) } } - slackLogger.info( - { messageCount: showcaseMessages.length }, - 'Sent showcase messages' - ) + slackLogger.info({ messageCount: messages.length }, `Sent ${label} messages`) +} + +/** + * Send all showcase messages to the #showcase channel. + * Clears existing messages first to prevent duplicates across restarts. + * Reusable: called both on startup (auto-populate) and via /showcase command. + */ +export async function sendShowcaseMessages(client: App['client']) { + await postMessages(client, showcaseMessages, 'showcase') +} + +/** + * Send all block-kit messages to the #showcase channel. + * Clears existing messages first to prevent duplicates across restarts. + */ +export async function sendBlockKitMessages(client: App['client']) { + await postMessages(client, blockKitMessages, 'block-kit') } export const HELP_TEXT = [ '*/showcase* commands:', - '- `/showcase generate` -- Populate #showcase with Block Kit examples', + '- `/showcase generate` -- Populate #showcase with Block Kit examples (JSON)', + '- `/showcase block-kit` -- Populate #showcase with Block Kit examples (block-kit functions)', '- `/showcase clear` -- Clear all messages from #showcase', '- `/showcase modal` -- Open a modal with input elements', '- `/showcase help` -- Show this help message', @@ -86,6 +119,10 @@ export function register(app: App) { await sendShowcaseMessages(client) break + case 'block-kit': + await sendBlockKitMessages(client) + break + case 'clear': await clearShowcaseChannel(client) await client.chat.postEphemeral({ @@ -99,228 +136,107 @@ export function register(app: App) { try { await client.views.open({ trigger_id: command.trigger_id, - view: { - type: 'modal', + view: modal({ + title: 'Input Showcase', + submit: 'Submit', + close: 'Cancel', callback_id: 'showcase_modal', - title: { type: 'plain_text', text: 'Input Showcase' }, - submit: { type: 'plain_text', text: 'Submit' }, - close: { type: 'plain_text', text: 'Cancel' }, blocks: [ - { - type: 'input', - label: { type: 'plain_text', text: 'Your Name' }, - hint: { - type: 'plain_text', - text: 'Enter your full name', - }, - element: { - type: 'plain_text_input', - action_id: 'showcase_modal_name', - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Email Address' }, - element: { - type: 'email_text_input', - action_id: 'showcase_modal_email', - placeholder: { - type: 'plain_text', - text: 'name@example.com', - }, - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Website' }, - element: { - type: 'url_text_input', - action_id: 'showcase_modal_url', - placeholder: { - type: 'plain_text', - text: 'https://example.com', - }, - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Quantity' }, - hint: { - type: 'plain_text', - text: 'Enter a number between 1 and 100', - }, - element: { - type: 'number_input', - action_id: 'showcase_modal_quantity', - is_decimal_allowed: false, + input('Your Name', textInput('showcase_modal_name'), { + hint: 'Enter your full name', + }), + input( + 'Email Address', + emailInput('showcase_modal_email', { + placeholder: 'name@example.com', + }) + ), + input( + 'Website', + urlInput('showcase_modal_url', { + placeholder: 'https://example.com', + }) + ), + input( + 'Quantity', + numberInput('showcase_modal_quantity', { min_value: '1', max_value: '100', - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Start Date' }, - element: { - type: 'datepicker', - action_id: 'showcase_modal_start_date', - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Start Time' }, - element: { - type: 'timepicker', - action_id: 'showcase_modal_start_time', - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Event DateTime' }, - element: { - type: 'datetimepicker', - action_id: 'showcase_modal_event_datetime', - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Priority Level' }, - element: { - type: 'radio_buttons', - action_id: 'showcase_modal_priority', - options: [ - { - text: { type: 'plain_text', text: 'Low' }, - value: 'low', - }, - { - text: { type: 'plain_text', text: 'Medium' }, - value: 'medium', - }, - { - text: { type: 'plain_text', text: 'High' }, - value: 'high', - }, - ], - }, - }, - { - type: 'input', - label: { - type: 'plain_text', - text: 'Notification Preferences', - }, - element: { - type: 'checkboxes', - action_id: 'showcase_modal_notifications', - options: [ - { - text: { type: 'plain_text', text: 'Email' }, - value: 'email', - }, - { - text: { type: 'plain_text', text: 'SMS' }, - value: 'sms', - }, - { - text: { type: 'plain_text', text: 'Push' }, - value: 'push', - }, - ], - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Department' }, - element: { - type: 'static_select', - action_id: 'showcase_modal_department', - placeholder: { - type: 'plain_text', - text: 'Select a department', - }, - options: [ - { - text: { type: 'plain_text', text: 'Engineering' }, - value: 'engineering', - }, - { - text: { type: 'plain_text', text: 'Design' }, - value: 'design', - }, - { - text: { type: 'plain_text', text: 'Marketing' }, - value: 'marketing', - }, - { - text: { type: 'plain_text', text: 'Sales' }, - value: 'sales', - }, - ], - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Skills' }, - element: { - type: 'multi_static_select', - action_id: 'showcase_modal_skills', - placeholder: { - type: 'plain_text', - text: 'Select your skills', - }, - options: [ - { - text: { type: 'plain_text', text: 'JavaScript' }, - value: 'javascript', - }, - { - text: { type: 'plain_text', text: 'TypeScript' }, - value: 'typescript', - }, - { - text: { type: 'plain_text', text: 'Python' }, - value: 'python', - }, - { - text: { type: 'plain_text', text: 'Rust' }, - value: 'rust', - }, - { - text: { type: 'plain_text', text: 'Go' }, - value: 'go', - }, - ], - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Attachments' }, - element: { - type: 'file_input', - action_id: 'showcase_modal_attachments', - max_files: 3, - }, - }, - { - type: 'input', - label: { type: 'plain_text', text: 'Additional Notes' }, - hint: { - type: 'plain_text', - text: 'Any extra details or comments', - }, - optional: true, - element: { - type: 'plain_text_input', - action_id: 'showcase_modal_notes', + }), + { hint: 'Enter a number between 1 and 100' } + ), + input('Start Date', datePicker('showcase_modal_start_date')), + input('Start Time', timePicker('showcase_modal_start_time')), + input( + 'Event DateTime', + dateTimePicker('showcase_modal_event_datetime') + ), + input( + 'Priority Level', + radioButtons( + 'showcase_modal_priority', + options([ + ['Low', 'low'], + ['Medium', 'medium'], + ['High', 'high'], + ]) + ) + ), + input( + 'Notification Preferences', + checkboxes( + 'showcase_modal_notifications', + options([ + ['Email', 'email'], + ['SMS', 'sms'], + ['Push', 'push'], + ]) + ) + ), + input( + 'Department', + staticSelect( + 'showcase_modal_department', + options([ + ['Engineering', 'engineering'], + ['Design', 'design'], + ['Marketing', 'marketing'], + ['Sales', 'sales'], + ]), + { placeholder: 'Select a department' } + ) + ), + input( + 'Skills', + multiStaticSelect( + 'showcase_modal_skills', + options([ + ['JavaScript', 'javascript'], + ['TypeScript', 'typescript'], + ['Python', 'python'], + ['Rust', 'rust'], + ['Go', 'go'], + ]), + { placeholder: 'Select your skills' } + ) + ), + input( + 'Attachments', + fileInput('showcase_modal_attachments', { max_files: 3 }) + ), + input( + 'Additional Notes', + textInput('showcase_modal_notes', { multiline: true, - placeholder: { - type: 'plain_text', - text: 'Enter any additional notes here...', - }, - }, - }, + placeholder: 'Enter any additional notes here...', + }), + { + hint: 'Any extra details or comments', + optional: true, + } + ), ], - }, + }), }) slackLogger.info('Opened showcase modal') } catch (err) { diff --git a/apps/showcase-bot/src/messages/block-kit-messages.ts b/apps/showcase-bot/src/messages/block-kit-messages.ts new file mode 100644 index 0000000..43b0012 --- /dev/null +++ b/apps/showcase-bot/src/messages/block-kit-messages.ts @@ -0,0 +1,815 @@ +import { + plainText, + option, + options, + button, + image, + overflow, + radioButtons, + checkboxes, + datePicker, + timePicker, + dateTimePicker, + staticSelect, + usersSelect, + multiUsersSelect, + conversationsSelect, + multiConversationsSelect, + channelsSelect, + multiChannelsSelect, + externalSelect, + multiExternalSelect, + section, + sectionFields, + header, + divider, + actions, + context, + imageBlock, + richText, + richLink, + richEmoji, + richSection, + richPreformatted, + richQuote, + richList, + richTextBlock, + cell, + table, +} from '@botarium/block-kit' + +const today = new Date() +const yyyy = today.getFullYear() +const mm = String(today.getMonth() + 1).padStart(2, '0') +const dd = String(today.getDate()).padStart(2, '0') +const todayDate = `${yyyy}-${mm}-${dd}` +const todayNoonUnix = Math.floor( + new Date(`${todayDate}T12:00:00Z`).getTime() / 1000 +) + +export const blockKitMessages = [ + // 01 - Text & Layout + { + text: 'Text & Layout Blocks', + blocks: [ + header('Block Kit Showcase'), + section( + '*Bold text*, _italic text_, ~strikethrough~, `inline code`, , and a blockquote:\n> This is a blockquote with *formatting*' + ), + sectionFields([ + '*Field 1*\nLeft column value', + '_Field 2_\nRight column value', + ]), + context([ + image('https://placecats.com/32/32', 'cat avatar'), + 'Posted by *Showcase Bot* | Context block with image and text', + ]), + divider(), + imageBlock('https://placecats.com/300/200', 'A placeholder cat image', { + title: 'Image Block', + }), + ], + }, + + // 02 - Button Variations + { + text: 'Button Variations', + blocks: [ + header('Buttons'), + actions([ + button('Primary', 'showcase_button_primary', { + value: 'primary_clicked', + style: 'primary', + }), + button('Danger', 'showcase_button_danger', { + value: 'danger_clicked', + style: 'danger', + }), + button('Default', 'showcase_button_default', { + value: 'default_clicked', + }), + ]), + ], + }, + + // 03 - Selection Elements + { + text: 'Selection Elements', + blocks: [ + header('Selection Elements'), + actions([ + staticSelect( + 'showcase_static_select', + options([ + ['Option A', 'option_a'], + ['Option B', 'option_b'], + ['Option C', 'option_c'], + ]), + { placeholder: 'Choose an option' } + ), + overflow( + 'showcase_overflow', + options([ + ['Edit', 'edit'], + ['Archive', 'archive'], + ['Delete', 'delete'], + ]) + ), + ]), + ], + }, + + // 04 - Radio Buttons & Checkboxes + { + text: 'Radio Buttons & Checkboxes', + blocks: [ + header('Radio Buttons & Checkboxes'), + section('*Size Selection* (radio buttons accessory)', { + accessory: radioButtons('showcase_radio_size', [ + option('Small', 'small', { description: 'Compact layout' }), + option('Medium', 'medium', { description: 'Standard layout' }), + option('Large', 'large', { description: 'Expanded layout' }), + ]), + }), + section('*Notification Preferences* (checkboxes accessory)', { + accessory: checkboxes('showcase_checkbox_notifications', [ + option('Email', 'email', { description: 'Daily digest' }), + option('SMS', 'sms', { description: 'Urgent only' }), + option('Push', 'push', { description: 'Real-time alerts' }), + ]), + }), + actions([ + radioButtons( + 'showcase_radio_priority', + options([ + ['Low', 'low'], + ['Medium', 'medium'], + ['High', 'high'], + ]) + ), + checkboxes( + 'showcase_checkbox_features', + options([ + ['Dark mode', 'dark_mode'], + ['Notifications', 'notifications'], + ['Auto-save', 'auto_save'], + ]) + ), + ]), + ], + }, + + // 05 - Date & Time Pickers + { + text: 'Date & Time Pickers', + blocks: [ + header('Date & Time Pickers'), + actions([ + datePicker('showcase_datepicker', { + initial_date: todayDate, + placeholder: 'Select a date', + }), + timePicker('showcase_timepicker', { + initial_time: '09:00', + placeholder: 'Select a time', + }), + dateTimePicker('showcase_datetimepicker', { + initial_date_time: todayNoonUnix, + }), + ]), + section('*Delivery Date* (datepicker accessory)', { + accessory: datePicker('showcase_datepicker_accessory', { + initial_date: todayDate, + placeholder: 'Select a delivery date', + }), + }), + section('*Meeting Time* (timepicker accessory)', { + accessory: timePicker('showcase_timepicker_accessory', { + initial_time: '14:30', + placeholder: 'Choose a meeting time', + }), + }), + ], + }, + + // 06 - Section Accessories + { + text: 'Section Accessories', + blocks: [ + header('Section Accessories'), + section('Click the action button', { + accessory: button('Action', 'showcase_section_button', { + value: 'section_button_clicked', + }), + }), + section('Choose a priority', { + accessory: staticSelect( + 'showcase_section_select', + options([ + ['Low', 'low'], + ['Medium', 'medium'], + ['High', 'high'], + ['Critical', 'critical'], + ]), + { placeholder: 'Select priority' } + ), + }), + section('More options available', { + accessory: overflow( + 'showcase_section_overflow', + options([ + ['Settings', 'settings'], + ['Help', 'help'], + ['About', 'about'], + ]) + ), + }), + section('Section with an image accessory', { + accessory: image('https://placecats.com/128/128', 'A cute cat'), + }), + ], + }, + + // 07 - Combined Actions + { + text: 'Combined Actions', + blocks: [ + header('Combined Actions'), + actions([ + button('Submit', 'showcase_combined_button', { + style: 'primary', + value: 'submit', + }), + staticSelect( + 'showcase_combined_select', + options([ + ['Alpha', 'alpha'], + ['Beta', 'beta'], + ['Gamma', 'gamma'], + ]), + { placeholder: 'Pick one' } + ), + datePicker('showcase_combined_datepicker', { + initial_date: todayDate, + placeholder: 'Pick a date', + }), + overflow( + 'showcase_combined_overflow', + options([ + ['Export', 'export'], + ['Print', 'print'], + ['Share', 'share'], + ]) + ), + ]), + ], + }, + + // 08 - Rich Text + { + text: 'Rich Text', + blocks: [ + richTextBlock([ + richSection([ + 'Check out these different block types with paragraph breaks between them:\n\n', + ]), + richPreformatted([ + 'Hello there, I am preformatted block!\n\nI can have multiple paragraph breaks within the block.', + ]), + richSection([ + '\nI am rich text with a paragraph break following preformatted text. \n\nI can have multiple paragraph breaks within the block.\n\n', + ]), + richQuote([ + 'I am a basic rich text quote, \n\nI can have multiple paragraph breaks within the block.', + ]), + richSection([ + '\nI am rich text with a paragraph after the quote block\n\n', + ]), + richQuote(['I am a basic quote block following rich text']), + richSection(['\n']), + richPreformatted([ + 'I am more preformatted text following a quote block', + ]), + richSection(['\n']), + richQuote(['I am a basic quote block following preformatted text']), + richSection(['\n']), + richList( + 'bullet', + [richSection(['list item one']), richSection(['list item two'])], + { indent: 0 } + ), + richSection(['\nI am rich text with a paragraph break after a list']), + ]), + context([ + '*This* is :smile: markdown', + image( + 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg', + 'cute cat' + ), + image( + 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg', + 'cute cat' + ), + image( + 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg', + 'cute cat' + ), + plainText('Author: K A Applegate'), + ]), + ], + }, + + // 09 - Template: Newsletter + { + text: 'Newsletter', + blocks: [ + header(':newspaper: Paper Company Newsletter :newspaper:'), + context(['*November 12, 2019* | Sales Team Announcements']), + divider(), + section(' :loud_sound: *IN CASE YOU MISSED IT* :loud_sound:'), + section( + 'Replay our screening of *Threat Level Midnight* and pick up a copy of the DVD to give to your customers at the front desk.', + { + accessory: button('Watch Now', 'showcase_newsletter_watch'), + } + ), + section( + 'The *2019 Dundies* happened. \nAwards were given, heroes were recognized. \nCheck out *#dundies-2019* to see who won awards.' + ), + divider(), + section(':calendar: | *UPCOMING EVENTS* | :calendar: '), + section( + '`11/20-11/22` *Beet the Competition* _ annual retreat at Schrute Farms_', + { + accessory: button('RSVP', 'showcase_newsletter_rsvp_retreat'), + } + ), + section("`12/01` *Toby's Going Away Party* at _Benihana_", { + accessory: button('Learn More', 'showcase_newsletter_learn_more'), + }), + section( + '`11/13` :pretzel: *Pretzel Day* :pretzel: at _Scranton Office_', + { + accessory: button('RSVP', 'showcase_newsletter_rsvp_pretzel'), + } + ), + divider(), + section(':calendar: | *PAST EVENTS* | :calendar: '), + section('`10/21` *Conference Room Meeting*', { + accessory: button('Watch Recording', 'showcase_newsletter_recording'), + }), + divider(), + section('*FOR YOUR INFORMATION*'), + section( + ':printer: *Sabre Printers* are no longer catching on fire! The newest version of our printers are safe to use. Make sure to tell your customers today.' + ), + divider(), + section( + 'Please join me in welcoming our 3 *new hires* to the Paper Company family! \n\n *Robert California*, CEO \n\n *Ryan Howard*, Temp \n\n *Erin Hannon*, Receptionist ' + ), + divider(), + context([ + ":pushpin: Do you have something to include in the newsletter? Here's *how to submit content*.", + ]), + ], + }, + + // 10 - Kitchen Sink + { + text: 'Kitchen Sink', + blocks: [ + richTextBlock([ + richSection(['Hello there, I am a basic rich text block!']), + ]), + richTextBlock([ + richSection([ + 'Hello there, ', + richText('I am a bold rich text block!', { bold: true }), + ]), + ]), + richTextBlock([ + richSection([ + 'Hello there, ', + richText('I am a strikethrough rich text block!', { strike: true }), + ]), + ]), + richTextBlock([ + richSection([ + richEmoji('basketball'), + ' ', + richEmoji('snowboarder'), + ' ', + richEmoji('checkered_flag'), + ]), + ]), + richTextBlock([ + richSection(['Basic bullet list with rich elements\n']), + richList( + 'bullet', + [ + richSection(['item 1: ', richEmoji('basketball')]), + richSection(['item 2: ', 'this is a list item']), + richSection([ + 'item 3: ', + richLink('https://example.com/', 'with a link', { bold: true }), + ]), + richSection(['item 4: ', 'we are near the end']), + richSection(['item 5: ', 'this is the end']), + ], + { indent: 0 } + ), + ]), + richTextBlock([ + richSection([ + 'Check out these different block types with paragraph breaks between them:\n\n', + ]), + richPreformatted([ + 'Hello there, I am preformatted block!\n\nI can have multiple paragraph breaks within the block.', + ]), + richSection([ + '\nI am rich text with a paragraph break following preformatted text. \n\nI can have multiple paragraph breaks within the block.\n\n', + ]), + richQuote([ + 'I am a basic rich text quote, \n\nI can have multiple paragraph breaks within the block.', + ]), + richSection([ + '\nI am rich text with a paragraph after the quote block\n\n', + ]), + richQuote(['I am a basic quote block following rich text']), + richSection(['\n']), + richPreformatted([ + 'I am more preformatted text following a quote block', + ]), + richSection(['\n']), + richQuote(['I am a basic quote block following preformatted text']), + richSection(['\n']), + richList( + 'bullet', + [richSection(['list item one']), richSection(['list item two'])], + { indent: 0 } + ), + richSection(['\nI am rich text with a paragraph break after a list']), + ]), + // context_actions — Botarium-specific block, no block-kit factory + { + type: 'context_actions', + elements: [ + { + type: 'feedback_buttons', + action_id: 'showcase_feedback', + positive_button: { + text: { type: 'plain_text', text: 'Good Response' }, + value: 'positive', + }, + negative_button: { + text: { type: 'plain_text', text: 'Bad Response' }, + value: 'negative', + }, + }, + { + type: 'icon_button', + action_id: 'showcase_remove', + icon: 'trash', + text: { type: 'plain_text', text: 'Remove' }, + }, + ], + }, + actions([ + button('Click Me', 'showcase_kitchen_sink_0', { + value: 'click_me_123', + }), + ]), + actions([ + conversationsSelect('showcase_kitchen_sink_convo_0', { + placeholder: 'Select a conversation', + initial_conversation: 'G12345678', + }), + usersSelect('showcase_kitchen_sink_1', { + placeholder: 'Select a user', + initial_user: 'U12345678', + }), + channelsSelect('showcase_kitchen_sink_2', { + placeholder: 'Select a channel', + initial_channel: 'C12345678', + }), + ]), + actions([ + externalSelect('actionId-4', { + placeholder: 'Search external data', + }), + multiUsersSelect('actionId-5', { placeholder: 'Select users' }), + multiConversationsSelect('actionId-6', { + placeholder: 'Select conversations', + }), + multiChannelsSelect('actionId-7', { + placeholder: 'Select channels', + }), + multiExternalSelect('actionId-8', { + placeholder: 'Search external items', + }), + staticSelect( + 'showcase_kitchen_sink_3', + options([ + ['*plain_text option 0*', 'value-0'], + ['*plain_text option 1*', 'value-1'], + ['*plain_text option 2*', 'value-2'], + ]), + { placeholder: 'Select an item' } + ), + ]), + ], + }, + + // 11 - Images + { + text: 'Images', + blocks: [ + imageBlock( + 'https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg', + 'delicious tacos', + { title: 'I love tacos' } + ), + ], + }, + + // 12 - Tables + { + text: 'Tables', + blocks: [ + section('*Tables*'), + table( + [ + [ + cell('Feature', { bold: true }), + cell('Status', { bold: true }), + cell('Priority', { bold: true }), + ], + [ + cell('Authentication'), + cell('Complete', { bold: true }), + cell('High'), + ], + [ + cell('Dashboard'), + cell('In Progress', { italic: true }), + cell('Medium'), + ], + [cell('API v2'), cell('Planned'), cell('Low')], + [cell('Mobile App'), cell('Blocked', { bold: true }), cell('High')], + ], + { + column_settings: [ + { align: 'left', is_wrapped: true }, + { align: 'center' }, + { align: 'right' }, + ], + } + ), + ], + }, + + // 13 - Template: Approval + { + text: 'Approval', + blocks: [ + section( + 'You have a new request:\n**' + ), + section( + '*Type:*\nPaid time off\n*When:*\nAug 10-Aug 13\n*Hours:* 16.0 (2 days)\n*Remaining balance:* 32.0 hours (4 days)\n*Comments:* "Family in town, going camping!"', + { + accessory: image( + 'https://api.slack.com/img/blocks/bkb_template_images/approvalsNewDevice.png', + 'computer thumbnail' + ), + } + ), + actions([ + button('Approve', 'showcase_approval_approve_pto', { + style: 'primary', + value: 'click_me_123', + }), + button('Deny', 'showcase_approval_deny_pto', { + style: 'danger', + value: 'click_me_123', + }), + ]), + section( + 'You have a new request:\n**' + ), + sectionFields([ + '*Type:*\nComputer (laptop)', + '*When:*\nSubmitted Aug 10', + '*Last Update:*\nMar 10, 2015 (3 years, 5 months)', + "*Reason:*\nAll vowel keys aren't working.", + '*Specs:*\n"Cheetah Pro 15" - Fast, really fast', + ]), + actions([ + button('Approve', 'showcase_approval_approve_device', { + style: 'primary', + value: 'click_me_123', + }), + button('Deny', 'showcase_approval_deny_device', { + style: 'danger', + value: 'click_me_123', + }), + ]), + ], + }, + + // 14 - Template: Notification + { + text: 'Notification', + blocks: [ + section( + plainText('Looks like you have a scheduling conflict with this event:') + ), + divider(), + section( + '**\nTuesday, January 21 4:00-4:30pm\nBuilding 2 - Havarti Cheese (3)\n2 guests', + { + accessory: image( + 'https://api.slack.com/img/blocks/bkb_template_images/notifications.png', + 'calendar thumbnail' + ), + } + ), + context([ + image( + 'https://api.slack.com/img/blocks/bkb_template_images/notificationsWarningIcon.png', + 'notifications warning icon' + ), + '*Conflicts with Team Huddle: 4:15-4:30pm*', + ]), + divider(), + section('*Propose a new time:*'), + section('*Today - 4:30-5pm*\nEveryone is available: @iris, @zelda', { + accessory: button('Choose', 'showcase_notification_choose_today', { + value: 'click_me_123', + }), + }), + section('*Tomorrow - 4-4:30pm*\nEveryone is available: @iris, @zelda', { + accessory: button('Choose', 'showcase_notification_choose_tomorrow_4', { + value: 'click_me_123', + }), + }), + section( + "*Tomorrow - 6-6:30pm*\nSome people aren't available: @iris, ~@zelda~", + { + accessory: button( + 'Choose', + 'showcase_notification_choose_tomorrow_6', + { value: 'click_me_123' } + ), + } + ), + section('**'), + ], + }, + + // 15 - Template: Vote + { + text: 'Vote', + blocks: [ + section( + '*Where should we order lunch from?* Poll by ' + ), + divider(), + section( + ':sushi: *Ace Wasabi Rock-n-Roll Sushi Bar*\nThe best landlocked sushi restaurant.', + { + accessory: button('Vote', 'showcase_vote_sushi', { + value: 'click_me_123', + }), + } + ), + context([ + image( + 'https://api.slack.com/img/blocks/bkb_template_images/profile_1.png', + 'Michael Scott' + ), + image( + 'https://api.slack.com/img/blocks/bkb_template_images/profile_2.png', + 'Dwight Schrute' + ), + image( + 'https://api.slack.com/img/blocks/bkb_template_images/profile_3.png', + 'Pam Beesly' + ), + plainText('3 votes'), + ]), + section( + ':hamburger: *Super Hungryman Hamburgers*\nOnly for the hungriest of the hungry.', + { + accessory: button('Vote', 'showcase_vote_hamburger', { + value: 'click_me_123', + }), + } + ), + context([ + image( + 'https://api.slack.com/img/blocks/bkb_template_images/profile_4.png', + 'Angela' + ), + image( + 'https://api.slack.com/img/blocks/bkb_template_images/profile_2.png', + 'Dwight Schrute' + ), + plainText('2 votes'), + ]), + section( + ':ramen: *Kagawa-Ya Udon Noodle Shop*\nDo you like to shop for noodles? We have noodles.', + { + accessory: button('Vote', 'showcase_vote_ramen', { + value: 'click_me_123', + }), + } + ), + context(['No votes']), + divider(), + actions([ + button('Add a suggestion', 'showcase_vote_add_suggestion', { + value: 'click_me_123', + }), + ]), + ], + }, + + // 16 - Search Results + { + text: 'Search Results', + blocks: [ + section( + 'We found *205 Hotels* in New Orleans, LA from *12/14 to 12/17*', + { + accessory: overflow( + 'showcase_search_results_filter', + options([ + ['Option One', 'value-0'], + ['Option Two', 'value-1'], + ['Option Three', 'value-2'], + ['Option Four', 'value-3'], + ]) + ), + } + ), + divider(), + section( + '**\n\u2605\u2605\u2605\u2605\u2605\n$340 per night\nRated: 9.4 - Excellent', + { + accessory: image( + 'https://api.slack.com/img/blocks/bkb_template_images/tripAgent_1.png', + 'Windsor Court Hotel thumbnail' + ), + } + ), + context([ + image( + 'https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png', + 'Location Pin Icon' + ), + plainText('Location: Central Business District'), + ]), + divider(), + section( + '**\n\u2605\u2605\u2605\u2605\u2605\n$340 per night\nRated: 9.1 - Excellent', + { + accessory: image( + 'https://api.slack.com/img/blocks/bkb_template_images/tripAgent_2.png', + 'Ritz-Carlton New Orleans thumbnail' + ), + } + ), + context([ + image( + 'https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png', + 'Location Pin Icon' + ), + plainText('Location: French Quarter'), + ]), + divider(), + section( + '**\n\u2605\u2605\u2605\u2605\u2605\n$419 per night\nRated: 8.8 - Excellent', + { + accessory: image( + 'https://api.slack.com/img/blocks/bkb_template_images/tripAgent_3.png', + 'Omni Royal Orleans Hotel thumbnail' + ), + } + ), + context([ + image( + 'https://api.slack.com/img/blocks/bkb_template_images/tripAgentLocationMarker.png', + 'Location Pin Icon' + ), + plainText('Location: French Quarter'), + ]), + divider(), + actions([ + button('Next 2 Results', 'showcase_search_results_next', { + value: 'click_me_123', + }), + ]), + ], + }, +] diff --git a/apps/showcase-bot/src/messages/blocks/01-text-and-layout.json b/apps/showcase-bot/src/messages/blocks/01-text-and-layout.json index c4a87e7..dbb68d0 100644 --- a/apps/showcase-bot/src/messages/blocks/01-text-and-layout.json +++ b/apps/showcase-bot/src/messages/blocks/01-text-and-layout.json @@ -1,5 +1,5 @@ { - "fallbackText": "Text & Layout Blocks", + "text": "Text & Layout Blocks", "blocks": [ { "type": "header", diff --git a/apps/showcase-bot/src/messages/blocks/02-button-variations.json b/apps/showcase-bot/src/messages/blocks/02-button-variations.json index 96766dc..a571636 100644 --- a/apps/showcase-bot/src/messages/blocks/02-button-variations.json +++ b/apps/showcase-bot/src/messages/blocks/02-button-variations.json @@ -1,5 +1,5 @@ { - "fallbackText": "Button Variations", + "text": "Button Variations", "blocks": [ { "type": "header", diff --git a/apps/showcase-bot/src/messages/blocks/03-selection-elements.json b/apps/showcase-bot/src/messages/blocks/03-selection-elements.json index b01e816..ec5a69c 100644 --- a/apps/showcase-bot/src/messages/blocks/03-selection-elements.json +++ b/apps/showcase-bot/src/messages/blocks/03-selection-elements.json @@ -1,5 +1,5 @@ { - "fallbackText": "Selection Elements", + "text": "Selection Elements", "blocks": [ { "type": "header", diff --git a/apps/showcase-bot/src/messages/blocks/04-radio-and-checkboxes.json b/apps/showcase-bot/src/messages/blocks/04-radio-and-checkboxes.json index 0b1ab02..d8ccabb 100644 --- a/apps/showcase-bot/src/messages/blocks/04-radio-and-checkboxes.json +++ b/apps/showcase-bot/src/messages/blocks/04-radio-and-checkboxes.json @@ -1,5 +1,5 @@ { - "fallbackText": "Radio Buttons & Checkboxes", + "text": "Radio Buttons & Checkboxes", "blocks": [ { "type": "header", diff --git a/apps/showcase-bot/src/messages/blocks/05-date-and-time-pickers.json b/apps/showcase-bot/src/messages/blocks/05-date-and-time-pickers.json index 8efb30a..4a85472 100644 --- a/apps/showcase-bot/src/messages/blocks/05-date-and-time-pickers.json +++ b/apps/showcase-bot/src/messages/blocks/05-date-and-time-pickers.json @@ -1,5 +1,5 @@ { - "fallbackText": "Date & Time Pickers", + "text": "Date & Time Pickers", "blocks": [ { "type": "header", diff --git a/apps/showcase-bot/src/messages/blocks/06-section-accessories.json b/apps/showcase-bot/src/messages/blocks/06-section-accessories.json index 8161675..33734fb 100644 --- a/apps/showcase-bot/src/messages/blocks/06-section-accessories.json +++ b/apps/showcase-bot/src/messages/blocks/06-section-accessories.json @@ -1,5 +1,5 @@ { - "fallbackText": "Section Accessories", + "text": "Section Accessories", "blocks": [ { "type": "header", diff --git a/apps/showcase-bot/src/messages/blocks/07-combined-actions.json b/apps/showcase-bot/src/messages/blocks/07-combined-actions.json index 741ecda..7769532 100644 --- a/apps/showcase-bot/src/messages/blocks/07-combined-actions.json +++ b/apps/showcase-bot/src/messages/blocks/07-combined-actions.json @@ -1,5 +1,5 @@ { - "fallbackText": "Combined Actions", + "text": "Combined Actions", "blocks": [ { "type": "header", diff --git a/apps/showcase-bot/src/messages/showcase-messages.ts b/apps/showcase-bot/src/messages/showcase-messages.ts index f095daa..588c307 100644 --- a/apps/showcase-bot/src/messages/showcase-messages.ts +++ b/apps/showcase-bot/src/messages/showcase-messages.ts @@ -3,7 +3,7 @@ import { join } from 'node:path' import type { KnownBlock } from '@slack/types' export interface ShowcaseMessage { - fallbackText: string + text?: string blocks: KnownBlock[] } diff --git a/bun.lock b/bun.lock index 3bd386e..a36f236 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "name": "showcase-bot", "version": "0.1.0", "dependencies": { + "@botarium/block-kit": "workspace:*", "@slack/bolt": "^4.6.0", "@slack/types": "^2.15.0", "@slack/web-api": "^7.10.0", @@ -76,6 +77,10 @@ "vite": "^7.3.1", }, }, + "packages/block-kit": { + "name": "@botarium/block-kit", + "version": "0.1.0", + }, "packages/core": { "name": "botarium", "version": "0.1.0", @@ -129,6 +134,8 @@ "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@botarium/block-kit": ["@botarium/block-kit@workspace:packages/block-kit"], + "@botarium/electron": ["@botarium/electron@workspace:apps/electron"], "@botarium/mrkdwn": ["@botarium/mrkdwn@workspace:packages/mrkdwn"], diff --git a/packages/block-kit/README.md b/packages/block-kit/README.md new file mode 100644 index 0000000..2c3a817 --- /dev/null +++ b/packages/block-kit/README.md @@ -0,0 +1,208 @@ +# @botarium/block-kit + +Lightweight factory functions for building Slack Block Kit JSON. Eliminates the verbosity of raw block objects while producing identical output. + +## Usage + +```ts +import { section, header, button, actions, options } from '@botarium/block-kit' +``` + +### Before (raw JSON) + +```ts +{ + type: 'header', + text: { type: 'plain_text', text: 'Status Update', emoji: true }, +} +``` + +### After (block-kit) + +```ts +header('Status Update') +``` + +## API + +### Text Objects + +```ts +plainText('Hello') // { type: 'plain_text', text: 'Hello', emoji: true } +mrkdwn('*bold*') // { type: 'mrkdwn', text: '*bold*' } +``` + +### Blocks + +```ts +header('Title') +section('*Bold* mrkdwn text') +section('With accessory', { accessory: button('Click', 'action_id') }) +sectionFields(['*Field 1*\nValue', '*Field 2*\nValue']) +divider() +actions([button('Go', 'go'), datePicker('pick_date')]) +context(['Posted by *Bot*', image('https://...', 'avatar')]) +input('Label', textInput('action_id'), { hint: 'Help text', optional: true }) +imageBlock('https://...', 'alt text', { title: 'Caption' }) +``` + +### Elements + +```ts +button('Click Me', 'action_id', { style: 'primary', value: 'v1' }) +image('https://...', 'alt text') +overflow( + 'action_id', + options([ + ['Edit', 'edit'], + ['Delete', 'delete'], + ]) +) +radioButtons( + 'action_id', + options([ + ['Low', 'low'], + ['High', 'high'], + ]) +) +checkboxes( + 'action_id', + options([ + ['A', 'a'], + ['B', 'b'], + ]) +) +datePicker('action_id', { initial_date: '2025-01-01' }) +timePicker('action_id', { initial_time: '09:00' }) +dateTimePicker('action_id') +``` + +### Input Elements + +```ts +textInput('action_id', { placeholder: 'Type here...', multiline: true }) +emailInput('action_id', { placeholder: 'name@example.com' }) +urlInput('action_id', { placeholder: 'https://...' }) +numberInput('action_id', { min_value: '1', max_value: '100' }) +fileInput('action_id', { max_files: 3 }) +``` + +### Select Menus + +```ts +staticSelect( + 'action_id', + options([ + ['A', 'a'], + ['B', 'b'], + ]), + { placeholder: 'Pick one' } +) +multiStaticSelect( + 'action_id', + options([ + ['A', 'a'], + ['B', 'b'], + ]) +) +usersSelect('action_id') +multiUsersSelect('action_id') +conversationsSelect('action_id') +multiConversationsSelect('action_id') +channelsSelect('action_id') +multiChannelsSelect('action_id') +externalSelect('action_id') +multiExternalSelect('action_id') +``` + +### Composition + +```ts +option('Label', 'value') +option('Label', 'value', { description: 'Extra info' }) +options([ + ['Label A', 'a'], + ['Label B', 'b'], +]) // shorthand for multiple options +confirmDialog({ + title: 'Sure?', + text: 'This cannot be undone', + confirm: 'Yes', + deny: 'No', +}) +``` + +### Rich Text + +```ts +richTextBlock([ + richSection(['Hello, ', richText('world!', { bold: true })]), + richPreformatted(['const x = 1']), + richQuote(['Something wise']), + richList('bullet', [richSection(['Item one']), richSection(['Item two'])]), +]) + +// Inline elements +richText('styled', { bold: true, italic: true }) +richLink('https://example.com', 'click here') +richEmoji('wave') +richUserMention('U123') +richChannelMention('C123') +richBroadcast('here') +``` + +### Tables + +```ts +// Simple table (first row auto-bolded as header) +simpleTable([ + ['Name', 'Role'], + ['Alice', 'Engineer'], + ['Bob', 'Designer'], +]) + +// Table with explicit cell styles and column settings +table( + [ + [cell('Feature', { bold: true }), cell('Status', { bold: true })], + [cell('Auth'), cell('Done', { italic: true })], + ], + { column_settings: [{ align: 'left' }, { align: 'center' }] } +) +``` + +### Surfaces (Modals & Home Tabs) + +```ts +modal({ + title: 'My Modal', + submit: 'Save', + close: 'Cancel', + callback_id: 'my_modal', + blocks: [ + input('Name', textInput('name_input')), + input( + 'Priority', + staticSelect( + 'priority', + options([ + ['Low', 'low'], + ['High', 'high'], + ]) + ) + ), + ], +}) + +homeTab({ + blocks: [section('Welcome!')], + callback_id: 'home', +}) +``` + +## Design Principles + +- **Zero dependencies** — pure TypeScript, no runtime deps +- **Structural compatibility** — output is plain JSON, works with `@slack/bolt`, `@slack/web-api`, or any HTTP client +- **String-first API** — pass strings where Slack expects text objects; they're auto-wrapped as `mrkdwn` (blocks) or `plain_text` (labels, buttons, placeholders) +- **Opt-in detail** — every function accepts an optional `opts` object for less common properties (`confirm`, `style`, `block_id`, etc.) diff --git a/packages/block-kit/package.json b/packages/block-kit/package.json new file mode 100644 index 0000000..bf400df --- /dev/null +++ b/packages/block-kit/package.json @@ -0,0 +1,19 @@ +{ + "name": "@botarium/block-kit", + "version": "0.1.0", + "description": "Lightweight Block Kit factory functions", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "files": [ + "src" + ], + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + } +} diff --git a/packages/block-kit/src/blocks.test.ts b/packages/block-kit/src/blocks.test.ts new file mode 100644 index 0000000..de87257 --- /dev/null +++ b/packages/block-kit/src/blocks.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'bun:test' +import { + section, + sectionFields, + header, + divider, + actions, + context, + input, + imageBlock, +} from './blocks.ts' +import { button, image } from './elements.ts' +import { textInput } from './inputs.ts' +import { plainText } from './text.ts' + +describe('section', () => { + test('wraps string as mrkdwn', () => { + expect(section('*bold*')).toEqual({ + type: 'section', + text: { type: 'mrkdwn', text: '*bold*' }, + }) + }) + + test('accepts pre-formed text object', () => { + const result = section(plainText('plain')) + expect(result.text).toEqual({ + type: 'plain_text', + text: 'plain', + emoji: true, + }) + }) + + test('with accessory', () => { + const btn = button('Click', 'btn') + const result = section('Text', { accessory: btn }) + expect(result.accessory).toEqual(btn) + }) + + test('with block_id', () => { + const result = section('Text', { block_id: 'sec_1' }) + expect(result.block_id).toBe('sec_1') + }) +}) + +describe('sectionFields', () => { + test('wraps strings as mrkdwn', () => { + const result = sectionFields(['*A*', '*B*']) + expect(result.fields).toEqual([ + { type: 'mrkdwn', text: '*A*' }, + { type: 'mrkdwn', text: '*B*' }, + ]) + }) +}) + +describe('header', () => { + test('wraps string as plain_text', () => { + expect(header('Title')).toEqual({ + type: 'header', + text: { type: 'plain_text', text: 'Title', emoji: true }, + }) + }) +}) + +describe('divider', () => { + test('creates divider', () => { + expect(divider()).toEqual({ type: 'divider' }) + }) + + test('with block_id', () => { + expect(divider({ block_id: 'div_1' })).toEqual({ + type: 'divider', + block_id: 'div_1', + }) + }) +}) + +describe('actions', () => { + test('creates actions block', () => { + const btn = button('Click', 'btn') + const result = actions([btn]) + expect(result.type).toBe('actions') + expect(result.elements).toEqual([btn]) + }) +}) + +describe('context', () => { + test('wraps strings as mrkdwn', () => { + const result = context(['Hello', '*world*']) + expect(result.elements).toEqual([ + { type: 'mrkdwn', text: 'Hello' }, + { type: 'mrkdwn', text: '*world*' }, + ]) + }) + + test('passes through image elements', () => { + const img = image('https://img.png', 'alt') + const result = context([img, 'text']) + expect(result.elements[0]).toEqual(img) + }) +}) + +describe('input', () => { + test('creates input block', () => { + const result = input('Name', textInput('name')) + expect(result).toEqual({ + type: 'input', + label: { type: 'plain_text', text: 'Name', emoji: true }, + element: { type: 'plain_text_input', action_id: 'name' }, + }) + }) + + test('with hint and optional', () => { + const result = input('Name', textInput('name'), { + hint: 'Enter your name', + optional: true, + }) + expect(result.hint).toEqual({ + type: 'plain_text', + text: 'Enter your name', + emoji: true, + }) + expect(result.optional).toBe(true) + }) +}) + +describe('imageBlock', () => { + test('creates image block', () => { + expect(imageBlock('https://img.png', 'Photo')).toEqual({ + type: 'image', + image_url: 'https://img.png', + alt_text: 'Photo', + }) + }) + + test('with title', () => { + const result = imageBlock('https://img.png', 'Photo', { + title: 'My photo', + }) + expect(result.title).toEqual({ + type: 'plain_text', + text: 'My photo', + emoji: true, + }) + }) +}) diff --git a/packages/block-kit/src/blocks.ts b/packages/block-kit/src/blocks.ts new file mode 100644 index 0000000..de6e53e --- /dev/null +++ b/packages/block-kit/src/blocks.ts @@ -0,0 +1,112 @@ +import type { + SectionBlock, + HeaderBlock, + DividerBlock, + ActionsBlock, + ContextBlock, + InputBlock, + ImageBlock, + TextObject, + PlainTextObject, + BlockElement, + InputElement, + ImageElement, +} from './types.ts' +import { resolveMrkdwn, resolvePlainText, mrkdwn } from './text.ts' + +export function section( + text: string | TextObject, + opts?: { accessory?: BlockElement; block_id?: string } +): SectionBlock { + return { + type: 'section', + text: resolveMrkdwn(text), + ...opts, + } +} + +export function sectionFields( + fields: (string | TextObject)[], + opts?: { accessory?: BlockElement; block_id?: string } +): SectionBlock { + return { + type: 'section', + fields: fields.map((f) => resolveMrkdwn(f)), + ...opts, + } +} + +export function header( + text: string | PlainTextObject, + opts?: { block_id?: string } +): HeaderBlock { + return { + type: 'header', + text: resolvePlainText(text), + ...opts, + } +} + +export function divider(opts?: { block_id?: string }): DividerBlock { + return { + type: 'divider', + ...opts, + } +} + +export function actions( + elements: BlockElement[], + opts?: { block_id?: string } +): ActionsBlock { + return { + type: 'actions', + elements, + ...opts, + } +} + +export function context( + elements: (string | TextObject | ImageElement)[], + opts?: { block_id?: string } +): ContextBlock { + return { + type: 'context', + elements: elements.map((el) => (typeof el === 'string' ? mrkdwn(el) : el)), + ...opts, + } +} + +export function input( + label: string | PlainTextObject, + element: InputElement, + opts?: { + hint?: string | PlainTextObject + optional?: boolean + block_id?: string + dispatch_action?: boolean + } +): InputBlock { + const { hint, ...rest } = opts ?? {} + return { + type: 'input', + label: resolvePlainText(label), + element, + ...rest, + ...(hint !== undefined && { hint: resolvePlainText(hint) }), + } +} + +export function imageBlock( + url: string, + altText: string, + opts?: { title?: string | PlainTextObject; block_id?: string } +): ImageBlock { + const { title, ...rest } = opts ?? {} + return { + type: 'image', + image_url: url, + alt_text: altText, + ...rest, + ...(title !== undefined && { title: resolvePlainText(title) }), + } +} diff --git a/packages/block-kit/src/composition.test.ts b/packages/block-kit/src/composition.test.ts new file mode 100644 index 0000000..088ddd8 --- /dev/null +++ b/packages/block-kit/src/composition.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' +import { option, options, confirmDialog } from './composition.ts' +import { plainText } from './text.ts' + +describe('option', () => { + test('creates option with string text', () => { + expect(option('Low', 'low')).toEqual({ + text: { type: 'plain_text', text: 'Low', emoji: true }, + value: 'low', + }) + }) + + test('creates option with description', () => { + const result = option('Low', 'low', { description: 'Low priority' }) + expect(result.description).toEqual({ + type: 'plain_text', + text: 'Low priority', + emoji: true, + }) + }) + + test('accepts pre-formed text object', () => { + const result = option(plainText('Custom'), 'val') + expect(result.text).toEqual({ + type: 'plain_text', + text: 'Custom', + emoji: true, + }) + }) +}) + +describe('options', () => { + test('creates options from tuples', () => { + const result = options([ + ['A', 'a'], + ['B', 'b'], + ]) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + text: { type: 'plain_text', text: 'A', emoji: true }, + value: 'a', + }) + expect(result[1]).toEqual({ + text: { type: 'plain_text', text: 'B', emoji: true }, + value: 'b', + }) + }) +}) + +describe('confirmDialog', () => { + test('creates confirm dialog with strings', () => { + const result = confirmDialog({ + title: 'Are you sure?', + text: 'This cannot be undone', + confirm: 'Yes', + deny: 'No', + }) + expect(result).toEqual({ + title: { type: 'plain_text', text: 'Are you sure?', emoji: true }, + text: { type: 'mrkdwn', text: 'This cannot be undone' }, + confirm: { type: 'plain_text', text: 'Yes', emoji: true }, + deny: { type: 'plain_text', text: 'No', emoji: true }, + }) + }) + + test('includes style when provided', () => { + const result = confirmDialog({ + title: 'Delete?', + text: 'Gone forever', + confirm: 'Delete', + deny: 'Cancel', + style: 'danger', + }) + expect(result.style).toBe('danger') + }) + + test('accepts pre-formed text object for text field', () => { + const result = confirmDialog({ + title: 'Title', + text: plainText('Plain text body'), + confirm: 'OK', + deny: 'Cancel', + }) + expect(result.text).toEqual({ + type: 'plain_text', + text: 'Plain text body', + emoji: true, + }) + }) +}) diff --git a/packages/block-kit/src/composition.ts b/packages/block-kit/src/composition.ts new file mode 100644 index 0000000..36581db --- /dev/null +++ b/packages/block-kit/src/composition.ts @@ -0,0 +1,41 @@ +import type { + Option, + ConfirmDialog, + PlainTextObject, + TextObject, +} from './types.ts' +import { resolvePlainText, mrkdwn } from './text.ts' + +export function option( + text: string | PlainTextObject, + value: string, + opts?: { description?: string | PlainTextObject } +): Option { + return { + text: resolvePlainText(text), + value, + ...(opts?.description !== undefined && { + description: resolvePlainText(opts.description), + }), + } +} + +export function options(tuples: [string, string][]): Option[] { + return tuples.map(([text, value]) => option(text, value)) +} + +export function confirmDialog(config: { + title: string | PlainTextObject + text: string | TextObject + confirm: string | PlainTextObject + deny: string | PlainTextObject + style?: 'primary' | 'danger' +}): ConfirmDialog { + return { + title: resolvePlainText(config.title), + text: typeof config.text === 'string' ? mrkdwn(config.text) : config.text, + confirm: resolvePlainText(config.confirm), + deny: resolvePlainText(config.deny), + ...(config.style && { style: config.style }), + } +} diff --git a/packages/block-kit/src/elements.test.ts b/packages/block-kit/src/elements.test.ts new file mode 100644 index 0000000..d2b59d0 --- /dev/null +++ b/packages/block-kit/src/elements.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test' +import { + button, + image, + overflow, + radioButtons, + checkboxes, + datePicker, + timePicker, + dateTimePicker, +} from './elements.ts' +import { options } from './composition.ts' + +describe('button', () => { + test('creates button with string text', () => { + expect(button('Click', 'btn_click')).toEqual({ + type: 'button', + action_id: 'btn_click', + text: { type: 'plain_text', text: 'Click', emoji: true }, + }) + }) + + test('creates button with style and value', () => { + const result = button('Delete', 'btn_del', { + style: 'danger', + value: '123', + }) + expect(result.style).toBe('danger') + expect(result.value).toBe('123') + }) + + test('creates button with url', () => { + const result = button('Link', 'btn_link', { + url: 'https://example.com', + }) + expect(result.url).toBe('https://example.com') + }) +}) + +describe('image', () => { + test('creates image element', () => { + expect(image('https://img.png', 'An image')).toEqual({ + type: 'image', + image_url: 'https://img.png', + alt_text: 'An image', + }) + }) +}) + +describe('overflow', () => { + test('creates overflow menu', () => { + const opts = options([ + ['Edit', 'edit'], + ['Delete', 'delete'], + ]) + const result = overflow('more_actions', opts) + expect(result.type).toBe('overflow') + expect(result.action_id).toBe('more_actions') + expect(result.options).toHaveLength(2) + }) +}) + +describe('radioButtons', () => { + test('creates radio buttons', () => { + const opts = options([ + ['A', 'a'], + ['B', 'b'], + ]) + const result = radioButtons('radio_choice', opts) + expect(result.type).toBe('radio_buttons') + expect(result.options).toHaveLength(2) + }) + + test('supports initial_option', () => { + const opts = options([ + ['A', 'a'], + ['B', 'b'], + ]) + const result = radioButtons('radio', opts, { initial_option: opts[0]! }) + expect(result.initial_option).toEqual(opts[0]) + }) +}) + +describe('checkboxes', () => { + test('creates checkboxes', () => { + const opts = options([ + ['X', 'x'], + ['Y', 'y'], + ]) + const result = checkboxes('check', opts) + expect(result.type).toBe('checkboxes') + expect(result.options).toHaveLength(2) + }) +}) + +describe('datePicker', () => { + test('creates date picker', () => { + const result = datePicker('pick_date') + expect(result).toEqual({ type: 'datepicker', action_id: 'pick_date' }) + }) + + test('with initial date and placeholder', () => { + const result = datePicker('pick_date', { + initial_date: '2024-01-01', + placeholder: 'Choose date', + }) + expect(result.initial_date).toBe('2024-01-01') + expect(result.placeholder).toEqual({ + type: 'plain_text', + text: 'Choose date', + emoji: true, + }) + }) +}) + +describe('timePicker', () => { + test('creates time picker', () => { + const result = timePicker('pick_time') + expect(result.type).toBe('timepicker') + }) +}) + +describe('dateTimePicker', () => { + test('creates datetime picker', () => { + const result = dateTimePicker('pick_dt', { initial_date_time: 1700000000 }) + expect(result.type).toBe('datetimepicker') + expect(result.initial_date_time).toBe(1700000000) + }) +}) diff --git a/packages/block-kit/src/elements.ts b/packages/block-kit/src/elements.ts new file mode 100644 index 0000000..ef0eaa5 --- /dev/null +++ b/packages/block-kit/src/elements.ts @@ -0,0 +1,139 @@ +import type { + ButtonElement, + ImageElement, + OverflowElement, + RadioButtonsElement, + CheckboxesElement, + DatePickerElement, + TimePickerElement, + DateTimePickerElement, + Option, + OverflowOption, + ConfirmDialog, + PlainTextObject, +} from './types.ts' +import { resolvePlainText } from './text.ts' + +export function button( + text: string | PlainTextObject, + actionId: string, + opts?: { + value?: string + style?: 'primary' | 'danger' + url?: string + confirm?: ConfirmDialog + } +): ButtonElement { + return { + type: 'button', + action_id: actionId, + text: resolvePlainText(text), + ...opts, + } +} + +export function image(url: string, altText: string): ImageElement { + return { type: 'image', image_url: url, alt_text: altText } +} + +export function overflow( + actionId: string, + options: OverflowOption[], + opts?: { confirm?: ConfirmDialog } +): OverflowElement { + return { + type: 'overflow', + action_id: actionId, + options, + ...opts, + } +} + +export function radioButtons( + actionId: string, + options: Option[], + opts?: { + initial_option?: Option + confirm?: ConfirmDialog + focus_on_load?: boolean + } +): RadioButtonsElement { + return { + type: 'radio_buttons', + action_id: actionId, + options, + ...opts, + } +} + +export function checkboxes( + actionId: string, + options: Option[], + opts?: { + initial_options?: Option[] + confirm?: ConfirmDialog + } +): CheckboxesElement { + return { + type: 'checkboxes', + action_id: actionId, + options, + ...opts, + } +} + +export function datePicker( + actionId: string, + opts?: { + initial_date?: string + placeholder?: string | PlainTextObject + confirm?: ConfirmDialog + focus_on_load?: boolean + } +): DatePickerElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'datepicker', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function timePicker( + actionId: string, + opts?: { + initial_time?: string + placeholder?: string | PlainTextObject + confirm?: ConfirmDialog + focus_on_load?: boolean + timezone?: string + } +): TimePickerElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'timepicker', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function dateTimePicker( + actionId: string, + opts?: { + initial_date_time?: number + confirm?: ConfirmDialog + focus_on_load?: boolean + } +): DateTimePickerElement { + return { + type: 'datetimepicker', + action_id: actionId, + ...opts, + } +} diff --git a/packages/block-kit/src/index.ts b/packages/block-kit/src/index.ts new file mode 100644 index 0000000..12f2c9a --- /dev/null +++ b/packages/block-kit/src/index.ts @@ -0,0 +1,58 @@ +export { plainText, mrkdwn } from './text.ts' +export { option, options, confirmDialog } from './composition.ts' +export { + button, + image, + overflow, + radioButtons, + checkboxes, + datePicker, + timePicker, + dateTimePicker, +} from './elements.ts' +export { + textInput, + emailInput, + urlInput, + numberInput, + fileInput, +} from './inputs.ts' +export { + staticSelect, + multiStaticSelect, + usersSelect, + multiUsersSelect, + conversationsSelect, + multiConversationsSelect, + channelsSelect, + multiChannelsSelect, + externalSelect, + multiExternalSelect, +} from './selects.ts' +export { + section, + sectionFields, + header, + divider, + actions, + context, + input, + imageBlock, +} from './blocks.ts' +export { + richText, + richLink, + richEmoji, + richUserMention, + richChannelMention, + richBroadcast, + richSection, + richPreformatted, + richQuote, + richList, + richTextBlock, +} from './rich-text.ts' +export { cell, rawCell, table, simpleTable } from './tables.ts' +export { modal, homeTab } from './surfaces.ts' + +export type * from './types.ts' diff --git a/packages/block-kit/src/inputs.test.ts b/packages/block-kit/src/inputs.test.ts new file mode 100644 index 0000000..17406ff --- /dev/null +++ b/packages/block-kit/src/inputs.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' +import { + textInput, + emailInput, + urlInput, + numberInput, + fileInput, +} from './inputs.ts' + +describe('textInput', () => { + test('creates plain text input', () => { + expect(textInput('msg')).toEqual({ + type: 'plain_text_input', + action_id: 'msg', + }) + }) + + test('with all options', () => { + const result = textInput('msg', { + placeholder: 'Type here...', + initial_value: 'hello', + multiline: true, + min_length: 1, + max_length: 100, + }) + expect(result.multiline).toBe(true) + expect(result.initial_value).toBe('hello') + expect(result.placeholder).toEqual({ + type: 'plain_text', + text: 'Type here...', + emoji: true, + }) + }) +}) + +describe('emailInput', () => { + test('creates email input', () => { + expect(emailInput('email')).toEqual({ + type: 'email_text_input', + action_id: 'email', + }) + }) +}) + +describe('urlInput', () => { + test('creates url input', () => { + expect(urlInput('url')).toEqual({ + type: 'url_text_input', + action_id: 'url', + }) + }) +}) + +describe('numberInput', () => { + test('creates number input with default decimal not allowed', () => { + const result = numberInput('num') + expect(result.type).toBe('number_input') + expect(result.is_decimal_allowed).toBe(false) + }) + + test('allows decimal', () => { + const result = numberInput('num', { is_decimal_allowed: true }) + expect(result.is_decimal_allowed).toBe(true) + }) +}) + +describe('fileInput', () => { + test('creates file input', () => { + expect(fileInput('file')).toEqual({ + type: 'file_input', + action_id: 'file', + }) + }) + + test('with filetypes', () => { + const result = fileInput('file', { + filetypes: ['png', 'jpg'], + max_files: 3, + }) + expect(result.filetypes).toEqual(['png', 'jpg']) + expect(result.max_files).toBe(3) + }) +}) diff --git a/packages/block-kit/src/inputs.ts b/packages/block-kit/src/inputs.ts new file mode 100644 index 0000000..5f7bf3d --- /dev/null +++ b/packages/block-kit/src/inputs.ts @@ -0,0 +1,105 @@ +import type { + PlainTextInputElement, + EmailInputElement, + UrlInputElement, + NumberInputElement, + FileInputElement, + PlainTextObject, +} from './types.ts' +import { resolvePlainText } from './text.ts' + +export function textInput( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_value?: string + multiline?: boolean + min_length?: number + max_length?: number + } +): PlainTextInputElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'plain_text_input', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function emailInput( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_value?: string + focus_on_load?: boolean + } +): EmailInputElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'email_text_input', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function urlInput( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_value?: string + focus_on_load?: boolean + } +): UrlInputElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'url_text_input', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function numberInput( + actionId: string, + opts?: { + is_decimal_allowed?: boolean + placeholder?: string | PlainTextObject + initial_value?: string + min_value?: string + max_value?: string + focus_on_load?: boolean + } +): NumberInputElement { + const { placeholder, is_decimal_allowed, ...rest } = opts ?? {} + return { + type: 'number_input', + action_id: actionId, + is_decimal_allowed: is_decimal_allowed ?? false, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function fileInput( + actionId: string, + opts?: { + filetypes?: string[] + max_files?: number + } +): FileInputElement { + return { + type: 'file_input', + action_id: actionId, + ...opts, + } +} diff --git a/packages/block-kit/src/rich-text.test.ts b/packages/block-kit/src/rich-text.test.ts new file mode 100644 index 0000000..f2e9532 --- /dev/null +++ b/packages/block-kit/src/rich-text.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'bun:test' +import { + richText, + richLink, + richEmoji, + richUserMention, + richChannelMention, + richBroadcast, + richSection, + richPreformatted, + richQuote, + richList, + richTextBlock, +} from './rich-text.ts' + +describe('richText', () => { + test('creates text element', () => { + expect(richText('hello')).toEqual({ type: 'text', text: 'hello' }) + }) + + test('with style', () => { + expect(richText('bold', { bold: true })).toEqual({ + type: 'text', + text: 'bold', + style: { bold: true }, + }) + }) +}) + +describe('richLink', () => { + test('creates link element', () => { + expect(richLink('https://example.com')).toEqual({ + type: 'link', + url: 'https://example.com', + }) + }) + + test('with text and style', () => { + expect( + richLink('https://example.com', 'Example', { italic: true }) + ).toEqual({ + type: 'link', + url: 'https://example.com', + text: 'Example', + style: { italic: true }, + }) + }) +}) + +describe('richEmoji', () => { + test('creates emoji element', () => { + expect(richEmoji('wave')).toEqual({ type: 'emoji', name: 'wave' }) + }) + + test('with unicode', () => { + expect(richEmoji('wave', { unicode: '1f44b' })).toEqual({ + type: 'emoji', + name: 'wave', + unicode: '1f44b', + }) + }) +}) + +describe('richUserMention', () => { + test('creates user mention', () => { + expect(richUserMention('U123')).toEqual({ + type: 'user', + user_id: 'U123', + }) + }) +}) + +describe('richChannelMention', () => { + test('creates channel mention', () => { + expect(richChannelMention('C123')).toEqual({ + type: 'channel', + channel_id: 'C123', + }) + }) +}) + +describe('richBroadcast', () => { + test('creates broadcast mention', () => { + expect(richBroadcast('here')).toEqual({ + type: 'broadcast', + range: 'here', + }) + }) +}) + +describe('richSection', () => { + test('auto-wraps strings', () => { + const result = richSection(['Hello ', richText('world', { bold: true })]) + expect(result).toEqual({ + type: 'rich_text_section', + elements: [ + { type: 'text', text: 'Hello ' }, + { type: 'text', text: 'world', style: { bold: true } }, + ], + }) + }) +}) + +describe('richPreformatted', () => { + test('creates preformatted block', () => { + const result = richPreformatted(['code here']) + expect(result.type).toBe('rich_text_preformatted') + expect(result.elements).toEqual([{ type: 'text', text: 'code here' }]) + }) +}) + +describe('richQuote', () => { + test('creates quote block', () => { + const result = richQuote(['quoted text']) + expect(result.type).toBe('rich_text_quote') + }) +}) + +describe('richList', () => { + test('creates list', () => { + const items = [richSection(['Item 1']), richSection(['Item 2'])] + const result = richList('bullet', items) + expect(result.type).toBe('rich_text_list') + expect(result.style).toBe('bullet') + expect(result.elements).toHaveLength(2) + }) +}) + +describe('richTextBlock', () => { + test('creates rich_text block', () => { + const sec = richSection(['Hello']) + const result = richTextBlock([sec]) + expect(result).toEqual({ + type: 'rich_text', + elements: [sec], + }) + }) + + test('with block_id', () => { + const result = richTextBlock([richSection(['text'])], { + block_id: 'rt_1', + }) + expect(result.block_id).toBe('rt_1') + }) +}) diff --git a/packages/block-kit/src/rich-text.ts b/packages/block-kit/src/rich-text.ts new file mode 100644 index 0000000..6a1fb91 --- /dev/null +++ b/packages/block-kit/src/rich-text.ts @@ -0,0 +1,152 @@ +import type { + RichTextTextElement, + RichTextLinkElement, + RichTextEmojiElement, + RichTextUserMentionElement, + RichTextChannelMentionElement, + RichTextBroadcastElement, + RichTextInlineElement, + RichTextSectionElement, + RichTextPreformattedElement, + RichTextQuoteElement, + RichTextListElement, + RichTextBlock, + RichTextBlockElement, + RichTextStyle, +} from './types.ts' + +// Inline elements + +export function richText( + text: string, + style?: RichTextStyle +): RichTextTextElement { + return { + type: 'text', + text, + ...(style && { style }), + } +} + +export function richLink( + url: string, + text?: string, + style?: RichTextStyle +): RichTextLinkElement { + return { + type: 'link', + url, + ...(text !== undefined && { text }), + ...(style && { style }), + } +} + +export function richEmoji( + name: string, + opts?: { unicode?: string; style?: RichTextStyle } +): RichTextEmojiElement { + return { + type: 'emoji', + name, + ...(opts?.unicode !== undefined && { unicode: opts.unicode }), + ...(opts?.style && { style: opts.style }), + } +} + +export function richUserMention( + userId: string, + style?: RichTextStyle +): RichTextUserMentionElement { + return { + type: 'user', + user_id: userId, + ...(style && { style }), + } +} + +export function richChannelMention( + channelId: string, + style?: RichTextStyle +): RichTextChannelMentionElement { + return { + type: 'channel', + channel_id: channelId, + ...(style && { style }), + } +} + +export function richBroadcast( + range: 'here' | 'channel' | 'everyone', + style?: RichTextStyle +): RichTextBroadcastElement { + return { + type: 'broadcast', + range, + ...(style && { style }), + } +} + +function resolveInline( + el: string | RichTextInlineElement +): RichTextInlineElement { + return typeof el === 'string' ? richText(el) : el +} + +// Block-level elements + +export function richSection( + elements: (string | RichTextInlineElement)[] +): RichTextSectionElement { + return { + type: 'rich_text_section', + elements: elements.map(resolveInline), + } +} + +export function richPreformatted( + elements: (string | RichTextInlineElement)[], + opts?: { border?: 0 | 1 } +): RichTextPreformattedElement { + return { + type: 'rich_text_preformatted', + elements: elements.map(resolveInline), + ...opts, + } +} + +export function richQuote( + elements: (string | RichTextInlineElement)[], + opts?: { border?: 0 | 1 } +): RichTextQuoteElement { + return { + type: 'rich_text_quote', + elements: elements.map(resolveInline), + ...opts, + } +} + +export function richList( + style: 'bullet' | 'ordered', + items: RichTextSectionElement[], + opts?: { indent?: number; border?: 0 | 1 } +): RichTextListElement { + return { + type: 'rich_text_list', + style, + elements: items, + ...opts, + } +} + +// Top-level block + +export function richTextBlock( + elements: RichTextBlockElement[], + opts?: { block_id?: string } +): RichTextBlock { + return { + type: 'rich_text', + elements, + ...opts, + } +} diff --git a/packages/block-kit/src/selects.test.ts b/packages/block-kit/src/selects.test.ts new file mode 100644 index 0000000..4971129 --- /dev/null +++ b/packages/block-kit/src/selects.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from 'bun:test' +import { + staticSelect, + multiStaticSelect, + usersSelect, + multiUsersSelect, + conversationsSelect, + multiConversationsSelect, + channelsSelect, + multiChannelsSelect, + externalSelect, + multiExternalSelect, +} from './selects.ts' +import { options } from './composition.ts' + +describe('staticSelect', () => { + test('creates static select', () => { + const opts = options([ + ['A', 'a'], + ['B', 'b'], + ]) + const result = staticSelect('sel', opts) + expect(result.type).toBe('static_select') + expect(result.action_id).toBe('sel') + expect(result.options).toHaveLength(2) + }) + + test('with placeholder', () => { + const opts = options([['A', 'a']]) + const result = staticSelect('sel', opts, { placeholder: 'Choose...' }) + expect(result.placeholder).toEqual({ + type: 'plain_text', + text: 'Choose...', + emoji: true, + }) + }) +}) + +describe('multiStaticSelect', () => { + test('creates multi static select', () => { + const opts = options([['A', 'a']]) + const result = multiStaticSelect('sel', opts) + expect(result.type).toBe('multi_static_select') + }) +}) + +describe('usersSelect', () => { + test('creates users select', () => { + expect(usersSelect('user')).toEqual({ + type: 'users_select', + action_id: 'user', + }) + }) +}) + +describe('multiUsersSelect', () => { + test('creates multi users select', () => { + expect(multiUsersSelect('users')).toEqual({ + type: 'multi_users_select', + action_id: 'users', + }) + }) +}) + +describe('conversationsSelect', () => { + test('creates conversations select', () => { + expect(conversationsSelect('conv')).toEqual({ + type: 'conversations_select', + action_id: 'conv', + }) + }) +}) + +describe('multiConversationsSelect', () => { + test('creates multi conversations select', () => { + expect(multiConversationsSelect('convs')).toEqual({ + type: 'multi_conversations_select', + action_id: 'convs', + }) + }) +}) + +describe('channelsSelect', () => { + test('creates channels select', () => { + expect(channelsSelect('chan')).toEqual({ + type: 'channels_select', + action_id: 'chan', + }) + }) +}) + +describe('multiChannelsSelect', () => { + test('creates multi channels select', () => { + expect(multiChannelsSelect('chans')).toEqual({ + type: 'multi_channels_select', + action_id: 'chans', + }) + }) +}) + +describe('externalSelect', () => { + test('creates external select', () => { + expect(externalSelect('ext')).toEqual({ + type: 'external_select', + action_id: 'ext', + }) + }) +}) + +describe('multiExternalSelect', () => { + test('creates multi external select', () => { + expect(multiExternalSelect('exts')).toEqual({ + type: 'multi_external_select', + action_id: 'exts', + }) + }) +}) diff --git a/packages/block-kit/src/selects.ts b/packages/block-kit/src/selects.ts new file mode 100644 index 0000000..2ef0717 --- /dev/null +++ b/packages/block-kit/src/selects.ts @@ -0,0 +1,208 @@ +import type { + StaticSelectElement, + MultiStaticSelectElement, + UsersSelectElement, + MultiUsersSelectElement, + ConversationsSelectElement, + MultiConversationsSelectElement, + ChannelsSelectElement, + MultiChannelsSelectElement, + ExternalSelectElement, + MultiExternalSelectElement, + Option, + ConfirmDialog, + PlainTextObject, +} from './types.ts' +import { resolvePlainText } from './text.ts' + +export function staticSelect( + actionId: string, + options: Option[], + opts?: { + placeholder?: string | PlainTextObject + initial_option?: Option + confirm?: ConfirmDialog + } +): StaticSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'static_select', + action_id: actionId, + options, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function multiStaticSelect( + actionId: string, + options: Option[], + opts?: { + placeholder?: string | PlainTextObject + initial_options?: Option[] + max_selected_items?: number + } +): MultiStaticSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'multi_static_select', + action_id: actionId, + options, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function usersSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_user?: string + } +): UsersSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'users_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function multiUsersSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_users?: string[] + max_selected_items?: number + } +): MultiUsersSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'multi_users_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function conversationsSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_conversation?: string + } +): ConversationsSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'conversations_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function multiConversationsSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_conversations?: string[] + max_selected_items?: number + } +): MultiConversationsSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'multi_conversations_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function channelsSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_channel?: string + } +): ChannelsSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'channels_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function multiChannelsSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_channels?: string[] + max_selected_items?: number + } +): MultiChannelsSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'multi_channels_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function externalSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_option?: Option + min_query_length?: number + } +): ExternalSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'external_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} + +export function multiExternalSelect( + actionId: string, + opts?: { + placeholder?: string | PlainTextObject + initial_options?: Option[] + min_query_length?: number + max_selected_items?: number + } +): MultiExternalSelectElement { + const { placeholder, ...rest } = opts ?? {} + return { + type: 'multi_external_select', + action_id: actionId, + ...rest, + ...(placeholder !== undefined && { + placeholder: resolvePlainText(placeholder), + }), + } +} diff --git a/packages/block-kit/src/surfaces.test.ts b/packages/block-kit/src/surfaces.test.ts new file mode 100644 index 0000000..d0cc179 --- /dev/null +++ b/packages/block-kit/src/surfaces.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' +import { modal, homeTab } from './surfaces.ts' +import { section, divider } from './blocks.ts' + +describe('modal', () => { + test('creates modal view', () => { + const result = modal({ + title: 'My Modal', + blocks: [section('Hello'), divider()], + submit: 'Submit', + close: 'Cancel', + }) + expect(result).toEqual({ + type: 'modal', + title: { type: 'plain_text', text: 'My Modal', emoji: true }, + blocks: [ + { type: 'section', text: { type: 'mrkdwn', text: 'Hello' } }, + { type: 'divider' }, + ], + submit: { type: 'plain_text', text: 'Submit', emoji: true }, + close: { type: 'plain_text', text: 'Cancel', emoji: true }, + }) + }) + + test('with callback_id and metadata', () => { + const result = modal({ + title: 'Form', + blocks: [], + callback_id: 'form_submit', + private_metadata: '{"key":"value"}', + }) + expect(result.callback_id).toBe('form_submit') + expect(result.private_metadata).toBe('{"key":"value"}') + }) + + test('without submit/close', () => { + const result = modal({ + title: 'Info', + blocks: [], + }) + expect(result.submit).toBeUndefined() + expect(result.close).toBeUndefined() + }) +}) + +describe('homeTab', () => { + test('creates home tab view', () => { + const result = homeTab({ + blocks: [section('Welcome')], + }) + expect(result).toEqual({ + type: 'home', + blocks: [{ type: 'section', text: { type: 'mrkdwn', text: 'Welcome' } }], + }) + }) + + test('with callback_id', () => { + const result = homeTab({ + blocks: [], + callback_id: 'home_tab', + }) + expect(result.callback_id).toBe('home_tab') + }) +}) diff --git a/packages/block-kit/src/surfaces.ts b/packages/block-kit/src/surfaces.ts new file mode 100644 index 0000000..55a9d2d --- /dev/null +++ b/packages/block-kit/src/surfaces.ts @@ -0,0 +1,38 @@ +import type { ModalView, HomeTabView, Block, PlainTextObject } from './types.ts' +import { resolvePlainText } from './text.ts' + +export function modal(config: { + title: string | PlainTextObject + blocks: Block[] + submit?: string | PlainTextObject + close?: string | PlainTextObject + callback_id?: string + private_metadata?: string + clear_on_close?: boolean + notify_on_close?: boolean + external_id?: string +}): ModalView { + const { title, blocks, submit, close, ...rest } = config + return { + type: 'modal', + title: resolvePlainText(title), + blocks, + ...(submit !== undefined && { submit: resolvePlainText(submit) }), + ...(close !== undefined && { close: resolvePlainText(close) }), + ...rest, + } +} + +export function homeTab(config: { + blocks: Block[] + callback_id?: string + private_metadata?: string + external_id?: string +}): HomeTabView { + const { blocks, ...rest } = config + return { + type: 'home', + blocks, + ...rest, + } +} diff --git a/packages/block-kit/src/tables.test.ts b/packages/block-kit/src/tables.test.ts new file mode 100644 index 0000000..84f8bd8 --- /dev/null +++ b/packages/block-kit/src/tables.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from 'bun:test' +import { cell, rawCell, table, simpleTable } from './tables.ts' + +describe('cell', () => { + test('creates rich_text cell from string', () => { + expect(cell('Hello')).toEqual({ + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [{ type: 'text', text: 'Hello' }], + }, + ], + }) + }) + + test('with style', () => { + const result = cell('Bold', { bold: true }) + expect(result.elements[0]!.elements[0]).toEqual({ + type: 'text', + text: 'Bold', + style: { bold: true }, + }) + }) +}) + +describe('rawCell', () => { + test('creates raw_text cell', () => { + expect(rawCell('raw')).toEqual({ type: 'raw_text', text: 'raw' }) + }) +}) + +describe('table', () => { + test('creates table block', () => { + const result = table([[cell('A'), cell('B')]]) + expect(result.type).toBe('table') + expect(result.rows).toHaveLength(1) + }) + + test('with column settings', () => { + const result = table([[cell('A')]], { + column_settings: [{ align: 'center' }], + }) + expect(result.column_settings).toEqual([{ align: 'center' }]) + }) +}) + +describe('simpleTable', () => { + test('auto-bolds first row', () => { + const result = simpleTable([ + ['Name', 'Age'], + ['Alice', '30'], + ]) + expect(result.rows).toHaveLength(2) + // First row should be bold + const headerCell = result.rows[0]![0]! + expect(headerCell.type).toBe('rich_text') + if (headerCell.type === 'rich_text') { + expect(headerCell.elements[0]!.elements[0]).toEqual({ + type: 'text', + text: 'Name', + style: { bold: true }, + }) + } + // Second row should not be bold + const dataCell = result.rows[1]![0]! + if (dataCell.type === 'rich_text') { + expect(dataCell.elements[0]!.elements[0]).toEqual({ + type: 'text', + text: 'Alice', + }) + } + }) +}) diff --git a/packages/block-kit/src/tables.ts b/packages/block-kit/src/tables.ts new file mode 100644 index 0000000..fe62883 --- /dev/null +++ b/packages/block-kit/src/tables.ts @@ -0,0 +1,52 @@ +import type { + RichTextCell, + RawTextElement, + TableBlock, + TableColumnSettings, + RichTextStyle, +} from './types.ts' + +export function cell(text: string, style?: RichTextStyle): RichTextCell { + return { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'text', + text, + ...(style && { style }), + }, + ], + }, + ], + } +} + +export function rawCell(text: string): RawTextElement { + return { type: 'raw_text', text } +} + +export function table( + rows: (RichTextCell | RawTextElement)[][], + opts?: { column_settings?: TableColumnSettings[]; block_id?: string } +): TableBlock { + return { + type: 'table', + rows, + ...opts, + } +} + +export function simpleTable( + rows: string[][], + opts?: { column_settings?: TableColumnSettings[]; block_id?: string } +): TableBlock { + return table( + rows.map((row, i) => + row.map((text) => (i === 0 ? cell(text, { bold: true }) : cell(text))) + ), + opts + ) +} diff --git a/packages/block-kit/src/text.test.ts b/packages/block-kit/src/text.test.ts new file mode 100644 index 0000000..9c877d1 --- /dev/null +++ b/packages/block-kit/src/text.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' +import { plainText, mrkdwn } from './text.ts' + +describe('plainText', () => { + test('creates plain_text object with emoji', () => { + expect(plainText('Hello')).toEqual({ + type: 'plain_text', + text: 'Hello', + emoji: true, + }) + }) +}) + +describe('mrkdwn', () => { + test('creates mrkdwn object', () => { + expect(mrkdwn('*bold*')).toEqual({ + type: 'mrkdwn', + text: '*bold*', + }) + }) +}) diff --git a/packages/block-kit/src/text.ts b/packages/block-kit/src/text.ts new file mode 100644 index 0000000..6a05151 --- /dev/null +++ b/packages/block-kit/src/text.ts @@ -0,0 +1,21 @@ +import type { PlainTextObject, MrkdwnObject, TextObject } from './types.ts' + +export function plainText(text: string): PlainTextObject { + return { type: 'plain_text', text, emoji: true } +} + +export function mrkdwn(text: string): MrkdwnObject { + return { type: 'mrkdwn', text } +} + +/** Resolve string to plain_text, or pass through existing text object */ +export function resolvePlainText( + text: string | PlainTextObject +): PlainTextObject { + return typeof text === 'string' ? plainText(text) : text +} + +/** Resolve string to mrkdwn, or pass through existing text object */ +export function resolveMrkdwn(text: string | TextObject): TextObject { + return typeof text === 'string' ? mrkdwn(text) : text +} diff --git a/packages/block-kit/src/types.ts b/packages/block-kit/src/types.ts new file mode 100644 index 0000000..36799b8 --- /dev/null +++ b/packages/block-kit/src/types.ts @@ -0,0 +1,493 @@ +// Text objects +export interface PlainTextObject { + type: 'plain_text' + text: string + emoji?: boolean +} + +export interface MrkdwnObject { + type: 'mrkdwn' + text: string + verbatim?: boolean +} + +export type TextObject = PlainTextObject | MrkdwnObject + +// Confirm dialog +export interface ConfirmDialog { + title: PlainTextObject + text: TextObject + confirm: PlainTextObject + deny: PlainTextObject + style?: 'primary' | 'danger' +} + +// Options +export interface Option { + text: PlainTextObject + value: string + description?: PlainTextObject +} + +export interface OverflowOption { + text: PlainTextObject + value: string + description?: PlainTextObject + url?: string +} + +// Interactive elements + +export interface ButtonElement { + type: 'button' + action_id: string + text: PlainTextObject + value?: string + style?: 'primary' | 'danger' + url?: string + confirm?: ConfirmDialog +} + +export interface ImageElement { + type: 'image' + image_url: string + alt_text: string +} + +export interface StaticSelectElement { + type: 'static_select' + action_id: string + placeholder?: PlainTextObject + options: Option[] + initial_option?: Option + confirm?: ConfirmDialog +} + +export interface MultiStaticSelectElement { + type: 'multi_static_select' + action_id: string + placeholder?: PlainTextObject + options: Option[] + initial_options?: Option[] + max_selected_items?: number + confirm?: ConfirmDialog +} + +export interface OverflowElement { + type: 'overflow' + action_id: string + options: OverflowOption[] + confirm?: ConfirmDialog +} + +export interface RadioButtonsElement { + type: 'radio_buttons' + action_id: string + options: Option[] + initial_option?: Option + confirm?: ConfirmDialog + focus_on_load?: boolean +} + +export interface CheckboxesElement { + type: 'checkboxes' + action_id: string + options: Option[] + initial_options?: Option[] + confirm?: ConfirmDialog + focus_on_load?: boolean +} + +export interface DatePickerElement { + type: 'datepicker' + action_id: string + initial_date?: string + placeholder?: PlainTextObject + confirm?: ConfirmDialog + focus_on_load?: boolean +} + +export interface TimePickerElement { + type: 'timepicker' + action_id: string + initial_time?: string + placeholder?: PlainTextObject + confirm?: ConfirmDialog + focus_on_load?: boolean + timezone?: string +} + +export interface DateTimePickerElement { + type: 'datetimepicker' + action_id: string + initial_date_time?: number + confirm?: ConfirmDialog + focus_on_load?: boolean +} + +// Input elements + +export interface PlainTextInputElement { + type: 'plain_text_input' + action_id: string + placeholder?: PlainTextObject + initial_value?: string + multiline?: boolean + min_length?: number + max_length?: number + focus_on_load?: boolean +} + +export interface NumberInputElement { + type: 'number_input' + action_id: string + is_decimal_allowed: boolean + initial_value?: string + min_value?: string + max_value?: string + placeholder?: PlainTextObject + focus_on_load?: boolean +} + +export interface EmailInputElement { + type: 'email_text_input' + action_id: string + initial_value?: string + placeholder?: PlainTextObject + focus_on_load?: boolean +} + +export interface UrlInputElement { + type: 'url_text_input' + action_id: string + initial_value?: string + placeholder?: PlainTextObject + focus_on_load?: boolean +} + +export interface FileInputElement { + type: 'file_input' + action_id: string + filetypes?: string[] + max_files?: number +} + +// Workspace select elements + +export interface UsersSelectElement { + type: 'users_select' + action_id: string + placeholder?: PlainTextObject + initial_user?: string + confirm?: ConfirmDialog +} + +export interface ConversationsSelectElement { + type: 'conversations_select' + action_id: string + placeholder?: PlainTextObject + initial_conversation?: string + confirm?: ConfirmDialog +} + +export interface ChannelsSelectElement { + type: 'channels_select' + action_id: string + placeholder?: PlainTextObject + initial_channel?: string + confirm?: ConfirmDialog +} + +export interface ExternalSelectElement { + type: 'external_select' + action_id: string + placeholder?: PlainTextObject + initial_option?: Option + min_query_length?: number + confirm?: ConfirmDialog +} + +export interface MultiUsersSelectElement { + type: 'multi_users_select' + action_id: string + placeholder?: PlainTextObject + initial_users?: string[] + max_selected_items?: number + confirm?: ConfirmDialog +} + +export interface MultiConversationsSelectElement { + type: 'multi_conversations_select' + action_id: string + placeholder?: PlainTextObject + initial_conversations?: string[] + max_selected_items?: number + confirm?: ConfirmDialog +} + +export interface MultiChannelsSelectElement { + type: 'multi_channels_select' + action_id: string + placeholder?: PlainTextObject + initial_channels?: string[] + max_selected_items?: number + confirm?: ConfirmDialog +} + +export interface MultiExternalSelectElement { + type: 'multi_external_select' + action_id: string + placeholder?: PlainTextObject + initial_options?: Option[] + min_query_length?: number + max_selected_items?: number + confirm?: ConfirmDialog +} + +// Union types + +export type BlockElement = + | ButtonElement + | ImageElement + | StaticSelectElement + | OverflowElement + | RadioButtonsElement + | CheckboxesElement + | DatePickerElement + | TimePickerElement + | DateTimePickerElement + | UsersSelectElement + | ConversationsSelectElement + | ChannelsSelectElement + | ExternalSelectElement + | MultiUsersSelectElement + | MultiConversationsSelectElement + | MultiChannelsSelectElement + | MultiExternalSelectElement + | MultiStaticSelectElement + +export type InputElement = + | PlainTextInputElement + | StaticSelectElement + | MultiStaticSelectElement + | FileInputElement + | CheckboxesElement + | RadioButtonsElement + | NumberInputElement + | EmailInputElement + | UrlInputElement + | DatePickerElement + | TimePickerElement + | DateTimePickerElement + | UsersSelectElement + | ConversationsSelectElement + | ChannelsSelectElement + | ExternalSelectElement + | MultiUsersSelectElement + | MultiConversationsSelectElement + | MultiChannelsSelectElement + | MultiExternalSelectElement + +// Rich text types + +export interface RichTextStyle { + bold?: boolean + italic?: boolean + strike?: boolean + code?: boolean + underline?: boolean +} + +export interface RichTextTextElement { + type: 'text' + text: string + style?: RichTextStyle +} + +export interface RichTextLinkElement { + type: 'link' + url: string + text?: string + style?: RichTextStyle +} + +export interface RichTextEmojiElement { + type: 'emoji' + name: string + unicode?: string + style?: RichTextStyle +} + +export interface RichTextUserMentionElement { + type: 'user' + user_id: string + style?: RichTextStyle +} + +export interface RichTextChannelMentionElement { + type: 'channel' + channel_id: string + style?: RichTextStyle +} + +export interface RichTextBroadcastElement { + type: 'broadcast' + range: 'here' | 'channel' | 'everyone' + style?: RichTextStyle +} + +export type RichTextInlineElement = + | RichTextTextElement + | RichTextLinkElement + | RichTextEmojiElement + | RichTextUserMentionElement + | RichTextChannelMentionElement + | RichTextBroadcastElement + +export interface RichTextSectionElement { + type: 'rich_text_section' + elements: RichTextInlineElement[] +} + +export interface RichTextPreformattedElement { + type: 'rich_text_preformatted' + elements: RichTextInlineElement[] + border?: 0 | 1 +} + +export interface RichTextQuoteElement { + type: 'rich_text_quote' + elements: RichTextInlineElement[] + border?: 0 | 1 +} + +export interface RichTextListElement { + type: 'rich_text_list' + style: 'bullet' | 'ordered' + elements: RichTextSectionElement[] + indent?: number + border?: 0 | 1 +} + +export type RichTextBlockElement = + | RichTextSectionElement + | RichTextPreformattedElement + | RichTextQuoteElement + | RichTextListElement + +// Block types + +export interface SectionBlock { + type: 'section' + block_id?: string + text?: TextObject + fields?: TextObject[] + accessory?: BlockElement +} + +export interface InputBlock { + type: 'input' + block_id?: string + label: PlainTextObject + element: InputElement + hint?: PlainTextObject + optional?: boolean + dispatch_action?: boolean +} + +export interface ActionsBlock { + type: 'actions' + block_id?: string + elements: BlockElement[] +} + +export interface DividerBlock { + type: 'divider' + block_id?: string +} + +export interface ContextBlock { + type: 'context' + block_id?: string + elements: Array +} + +export interface ImageBlock { + type: 'image' + block_id?: string + image_url: string + alt_text: string + title?: PlainTextObject +} + +export interface HeaderBlock { + type: 'header' + block_id?: string + text: PlainTextObject +} + +export interface RichTextBlock { + type: 'rich_text' + block_id?: string + elements: RichTextBlockElement[] +} + +// Raw text (for table cells) +export interface RawTextElement { + type: 'raw_text' + text: string +} + +// Table types +export interface RichTextCell { + type: 'rich_text' + elements: RichTextBlockElement[] +} + +export interface TableColumnSettings { + align?: 'left' | 'center' | 'right' + is_wrapped?: boolean +} + +export interface TableBlock { + type: 'table' + block_id?: string + rows: (RichTextCell | RawTextElement)[][] + column_settings?: TableColumnSettings[] +} + +// All blocks +export type Block = + | SectionBlock + | InputBlock + | ActionsBlock + | DividerBlock + | ContextBlock + | ImageBlock + | HeaderBlock + | RichTextBlock + | TableBlock + +// View (modal / home tab) +interface BaseView { + title?: PlainTextObject + blocks: Block[] + private_metadata?: string + callback_id?: string + external_id?: string +} + +export interface ModalView extends BaseView { + type: 'modal' + title: PlainTextObject + submit?: PlainTextObject + close?: PlainTextObject + clear_on_close?: boolean + notify_on_close?: boolean +} + +export interface HomeTabView extends BaseView { + type: 'home' +} + +export type View = ModalView | HomeTabView diff --git a/packages/block-kit/tsconfig.json b/packages/block-kit/tsconfig.json new file mode 100644 index 0000000..94128fa --- /dev/null +++ b/packages/block-kit/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}