reblock
Version:
Build interactive Slack surfaces with React
492 lines (479 loc) • 13 kB
text/typescript
import type { types as Slack } from '@slack/bolt'
import type { Instance, TextInstance } from '../renderer'
import {
assertNoChildren,
dateToSlackTimestamp,
getTextChild,
getTextProperty,
jsxToImageObject,
plainDateToString,
} from '../helpers'
import { Temporal } from 'temporal-polyfill'
export type BlockElement =
| Slack.SectionBlockAccessory
| Slack.InputBlockElement
| Slack.ActionsBlockElement
| Slack.ContextBlockElement
function jsxChildrenToOptions(
children: (Instance | TextInstance)[],
elementName: string,
plainTextOnly?: false
): {
options: Slack.Option[]
initial_options: Slack.Option[]
initial_option: Slack.Option
}
function jsxChildrenToOptions(
children: (Instance | TextInstance)[],
elementName: string,
plainTextOnly: true
): {
options: Slack.PlainTextOption[]
initial_options: Slack.PlainTextOption[]
initial_option: Slack.PlainTextOption
}
function jsxChildrenToOptions(
children: (Instance | TextInstance)[],
elementName: string,
plainTextOnly = false
) {
const options: Slack.Option[] = []
const selectedOptions: Slack.Option[] = []
for (const child of children) {
if (child.type !== 'instance') {
throw new Error(`Only ${elementName} elements allowed here`)
}
if (child.element !== elementName) {
throw new Error(`Only ${elementName} elements allowed here`)
}
if (plainTextOnly && child.props.mrkdwn) {
throw new Error('Only plain text allowed here')
}
const option =
child.props.mrkdwn && !plainTextOnly
? {
text: {
type: 'mrkdwn' as const,
text: getTextChild(child),
},
}
: {
text: {
type: 'plain_text' as const,
text: getTextChild(child),
},
}
options.push(option)
if (child.props.selected) {
selectedOptions.push(option)
}
}
return {
options,
initial_options: selectedOptions,
initial_option: selectedOptions[0],
}
}
export function jsxToBlockElement(jsx: Instance | TextInstance): BlockElement {
if (jsx.type === 'text') {
return {
type: 'plain_text',
text: jsx.text,
} satisfies Slack.PlainTextElement
}
if (jsx.element === 'mrkdwn') {
return {
type: 'mrkdwn',
text: getTextChild(jsx),
} satisfies Slack.MrkdwnElement
}
// Common props
const confirm = jsx.props.confirm as Slack.ConfirmationDialog | undefined
const focus_on_load = !!jsx.props.focus
const placeholder = jsx.props.placeholder
? {
type: 'plain_text' as const,
text: getTextProperty(jsx.props.placeholder, true),
}
: undefined
const action_id = `reblock_${jsx.id}`
if (jsx.element === 'button') {
if (jsx.props.workflow) {
return {
type: 'workflow_button',
workflow: jsx.props.workflow as Slack.WorkflowButton['workflow'],
text: {
type: 'plain_text',
text: getTextChild(jsx),
},
style: jsx.props.primary
? 'primary'
: jsx.props.danger
? 'danger'
: undefined,
accessibility_label: getTextProperty(jsx.props.alt),
confirm,
} satisfies Slack.WorkflowButton
}
return {
type: 'button',
text: {
type: 'plain_text',
text: getTextChild(jsx),
},
url: getTextProperty(jsx.props.url),
style: jsx.props.primary
? 'primary'
: jsx.props.danger
? 'danger'
: undefined,
accessibility_label: getTextProperty(jsx.props.alt),
confirm,
action_id,
} satisfies Slack.Button
}
if (jsx.element === 'text') {
return {
type: 'plain_text_input',
initial_value: getTextProperty(jsx.props.initial),
multiline: !!jsx.props.multiline,
min_length: jsx.props.minLength ? Number(jsx.props.minLength) : undefined,
max_length: jsx.props.maxLength ? Number(jsx.props.maxLength) : undefined,
placeholder,
focus_on_load,
action_id,
} satisfies Slack.PlainTextInput
}
if (jsx.element === 'textarea') {
return {
type: 'rich_text_input',
// TODO: initial_value
placeholder,
focus_on_load,
action_id,
} satisfies Slack.RichTextInput
}
if (jsx.element === 'datepicker') {
assertNoChildren(jsx)
return {
type: 'datepicker',
initial_date: plainDateToString(jsx.props.initial),
confirm,
placeholder,
focus_on_load,
action_id,
} satisfies Slack.Datepicker
}
if (jsx.element === 'datetimepicker') {
assertNoChildren(jsx)
return {
type: 'datetimepicker',
initial_date_time: dateToSlackTimestamp(jsx.props.initial),
confirm,
focus_on_load,
action_id,
} satisfies Slack.DateTimepicker
}
if (jsx.element === 'timepicker') {
assertNoChildren(jsx)
const timezoneRaw = jsx.props.timezone
let timezone = getTextProperty(timezoneRaw)
if (timezoneRaw instanceof Temporal.TimeZone) {
timezone = timezoneRaw.id
}
const initialRaw = jsx.props.initial
let initial_time = getTextProperty(initialRaw)
if (initialRaw instanceof Temporal.PlainTime) {
initial_time = initialRaw.toString()
}
return {
type: 'timepicker',
initial_time,
timezone,
confirm,
placeholder,
focus_on_load,
action_id,
} satisfies Slack.Timepicker
}
if (jsx.element === 'email') {
assertNoChildren(jsx)
return {
type: 'email_text_input',
initial_value: getTextProperty(jsx.props.initial),
placeholder,
action_id,
} satisfies Slack.EmailInput
}
if (jsx.element === 'url') {
assertNoChildren(jsx)
return {
type: 'url_text_input',
initial_value: getTextProperty(jsx.props.initial),
placeholder,
focus_on_load,
action_id,
} satisfies Slack.URLInput
}
if (jsx.element === 'number') {
assertNoChildren(jsx)
const numberString = (input: unknown) => {
if (typeof input === 'number') {
return String(input)
}
if (typeof input === 'string') {
return input
}
return undefined
}
return {
type: 'number_input',
is_decimal_allowed: !!jsx.props.decimal,
initial_value: numberString(jsx.props.initial),
min_value: numberString(jsx.props.min),
max_value: numberString(jsx.props.max),
placeholder,
focus_on_load,
action_id,
} satisfies Slack.NumberInput
}
if (jsx.element === 'file') {
assertNoChildren(jsx)
return {
type: 'file_input',
filetypes: jsx.props.filetypes as string[] | undefined,
max_files: Number(jsx.props.maxFiles ?? 10),
action_id,
} satisfies Slack.FileInput
}
if (jsx.element === 'checkboxes') {
return {
type: 'checkboxes',
...jsxChildrenToOptions(jsx.children, 'checkbox'),
confirm,
focus_on_load,
action_id,
} satisfies Slack.Checkboxes
}
if (jsx.element === 'radio') {
return {
type: 'radio_buttons',
...jsxChildrenToOptions(jsx.children, 'option'),
confirm,
focus_on_load,
action_id,
} satisfies Slack.RadioButtons
}
if (jsx.element === 'select') {
return {
type: jsx.props.multi ? 'multi_static_select' : 'static_select',
...jsxChildrenToOptions(jsx.children, 'option', true),
max_selected_items: jsx.props.max as number | undefined,
placeholder,
confirm,
focus_on_load,
action_id,
} satisfies Slack.StaticSelect | Slack.MultiStaticSelect
}
if (jsx.element === 'selectuser') {
assertNoChildren(jsx)
if (jsx.props.multi) {
return {
type: 'multi_users_select',
initial_users: jsx.props.initial as string[] | undefined,
max_selected_items: jsx.props.max as number | undefined,
placeholder,
confirm,
focus_on_load,
action_id,
} satisfies Slack.MultiUsersSelect
}
return {
type: 'users_select',
initial_user: getTextProperty(jsx.props.initial),
placeholder,
confirm,
focus_on_load,
action_id,
} satisfies Slack.UsersSelect
}
if (jsx.element === 'selectconversation') {
assertNoChildren(jsx)
if (jsx.props.multi) {
return {
type: 'multi_conversations_select',
initial_conversations: jsx.props.initial as string[] | undefined,
max_selected_items: jsx.props.max as number | undefined,
default_to_current_conversation: !!jsx.props.defaultToCurrent,
filter: jsx.props.filter as Slack.ConversationFilter | undefined,
placeholder,
confirm,
focus_on_load,
action_id,
} satisfies Slack.MultiConversationsSelect
}
return {
type: 'conversations_select',
initial_conversation: getTextProperty(jsx.props.initial),
default_to_current_conversation: !!jsx.props.defaultToCurrent,
filter: jsx.props.filter as Slack.ConversationFilter | undefined,
placeholder,
confirm,
focus_on_load,
action_id,
} satisfies Slack.ConversationsSelect
}
if (jsx.element === 'selectchannel') {
assertNoChildren(jsx)
if (jsx.props.multi) {
return {
type: 'multi_channels_select',
initial_channels: jsx.props.initial as string[] | undefined,
max_selected_items: jsx.props.max as number | undefined,
placeholder,
confirm,
focus_on_load,
action_id,
} satisfies Slack.MultiChannelsSelect
}
return {
type: 'channels_select',
initial_channel: getTextProperty(jsx.props.initial),
placeholder,
confirm,
focus_on_load,
action_id,
}
}
if (jsx.element === 'overflow') {
return {
type: 'overflow',
options: jsxChildrenToOptions(jsx.children, 'option', true).options,
confirm,
action_id,
}
}
if (jsx.element === 'img') {
if (jsx.props.title) {
throw new Error(
'Title not allowed on image element, only image blocks allow titles'
)
}
return jsxToImageObject(jsx) satisfies Slack.ImageElement
}
throw new Error(`Unsupported block element: ${jsx.element}`)
}
export function blockElementIsSectionAccessory(
element: BlockElement
): element is Slack.SectionBlockAccessory {
return [
'image',
'button',
'checkboxes',
'datepicker',
'multi_users_select',
'multi_static_select',
'multi_conversations_select',
'multi_channels_select',
'multi_external_select',
'overflow',
'radio_buttons',
'users_select',
'static_select',
'conversations_select',
'channels_select',
'external_select',
'timepicker',
'workflow_button',
].includes(element.type)
}
/** The JSX tag names which correspond to block elements an input block allows */
export const inputBlockElementTagNames = [
'text',
'textarea',
'datepicker',
'datetimepicker',
'timepicker',
'email',
'url',
'number',
'file',
'checkboxes',
'radio',
'select',
'selectuser',
'selectconversation',
'selectchannel',
]
export function blockElementIsInputBlockElement(
element: BlockElement
): element is Slack.InputBlockElement {
return [
'checkboxes',
'datepicker',
'multi_users_select',
'multi_static_select',
'multi_conversations_select',
'multi_channels_select',
'multi_external_select',
'radio_buttons',
'users_select',
'static_select',
'conversations_select',
'channels_select',
'external_select',
'timepicker',
'datetimepicker',
'email_text_input',
'file_input',
'number_input',
'plain_text_input',
'rich_text_input',
'url_text_input',
].includes(element.type)
}
/** The JSX tag names which correspond to block elements an actions block allows */
export const actionsBlockElementTagNames = [
'button',
'checkboxes',
'datepicker',
'datetimepicker',
'timepicker',
'select',
'selectuser',
'selectconversation',
'selectchannel',
'overflow',
'radio',
'textarea',
]
export function blockElementIsActionsBlockElement(
element: BlockElement
): element is Slack.ActionsBlockElement {
return [
'button',
'checkboxes',
'datepicker',
'multi_users_select',
'multi_static_select',
'multi_conversations_select',
'multi_channels_select',
'multi_external_select',
'overflow',
'radio_buttons',
'users_select',
'static_select',
'conversations_select',
'channels_select',
'external_select',
'timepicker',
'workflow_button',
'datetimepicker',
'rich_text_input',
].includes(element.type)
}
export function blockElementIsContextBlockElement(
element: BlockElement
): element is Slack.ContextBlockElement {
return ['image', 'mrkdwn', 'plain_text'].includes(element.type)
}