diff --git a/.circleci/config.yml b/.circleci/config.yml index 3f2bf7b3..a2e8b251 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,7 +29,6 @@ commands: - save_cache: key: dependencies-{{ .Environment.CI_CACHE_KEY }}-{{ .Environment.CIRCLE_JOB }}-{{ checksum "yarn.lock" }}-{{ .Branch }} paths: - - node_modules - ~/.cache/yarn - run: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fe26ac8..9382d51e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- [``](https://github.com/speee/jsx-slack/blob/master/docs/block-elements.md#checkbox-group) and [``](https://github.com/speee/jsx-slack/blob/master/docs/block-elements.md#checkbox) interactive component ([#108](https://github.com/speee/jsx-slack/issues/108), [#109](https://github.com/speee/jsx-slack/pull/109)) +- [Redirect the content of `` element into `description`](https://github.com/speee/jsx-slack/blob/master/docs/block-elements.md#redirect-small-into-description) in `` and `` ([#109](https://github.com/speee/jsx-slack/pull/109)) + ### Changed - Upgrade dependent packages to the latest version ([#107](https://github.com/speee/jsx-slack/pull/107)) diff --git a/demo/schema.js b/demo/schema.js index a52a741e..fc6a500e 100644 --- a/demo/schema.js +++ b/demo/schema.js @@ -9,6 +9,7 @@ const blockInteractiveComponents = [ 'Overflow', 'DatePicker', 'RadioButtonGroup', + 'CheckboxGroup', // HTML compatible 'button', @@ -102,6 +103,7 @@ const schema = { 'ChannelsSelect', 'DatePicker', 'RadioButtonGroup', + 'CheckboxGroup', ], }, @@ -177,6 +179,7 @@ const schema = { 'ChannelsSelect', 'DatePicker', 'RadioButtonGroup', + 'CheckboxGroup', ], }, input: { @@ -197,6 +200,7 @@ const schema = { 'ChannelsSelect', 'DatePicker', 'RadioButtonGroup', + 'CheckboxGroup', ], }, @@ -313,6 +317,18 @@ const schema = { children: ['RadioButton'], }, RadioButton: { attrs: { value: null, description: null }, children: [] }, + CheckboxGroup: { + attrs: { + values: null, + ...blockInteractiveCommonAttrs, + ...inputComponentAttrs, + }, + children: ['Checkbox'], + }, + Checkbox: { + attrs: { value: null, checked: [], description: null }, + children: ['Mrkdwn', 'small', ...markupHTML], + }, // Composition objects Confirm: { @@ -385,6 +401,7 @@ const schema = { t => t !== 's' && t !== 'strike' && t !== 'del' ), }, + small: { attrs: {}, children: markupHTML }, span: { attrs: {}, children: markupHTML }, strike: { attrs: {}, diff --git a/docs/block-elements.md b/docs/block-elements.md index 7827c7ec..cb1ca35f 100644 --- a/docs/block-elements.md +++ b/docs/block-elements.md @@ -379,6 +379,135 @@ An easy way to let the user selecting any date is using `` component - `title`/ `hint` (optional): Specify a helpful text appears under the element. - `required` (optional): A boolean prop to specify whether any value must be filled when user confirms modal. +### [``: Checkbox group](https://api.slack.com/reference/block-kit/block-elements#checkboxes) (Only for modal and home tab) + +A container for grouping checkboxes. _This component is only for [``](block-containers.md#modal) and [``](block-containers.md#home) container. It cannot use in [``](block-containers.md#blocks) container for messaging._ + +```jsx + +
+ ToDo List + + + Learn about Slack app ( + ) + + + XXX-0001: High + + + + + Learn about jsx-slack ( + ) + + + XXX-0002: Medium + + + + + + Prepare development environment ( + ) + + + + XXX-0003: Medium + + + + +
+
+``` + +[]() + +#### Props + +- `name` / `actionId` (optional): An identifier for the action. +- `values` (optional): An array of value for initially selected checkboxes. They must match to `value` property in `` elements in children. +- `confirm` (optional): [`` element](#confirm) to show confirmation dialog. + +#### As [an input component for modal](#input-components-for-modal) + +```jsx + + + Burger :hamburger: + Pizza :pizza: + Tex-Mex taco :taco: + Sushi :sushi: + + Others + + Let me know in the below form. + + + + + +``` + +[](https://api.slack.com/tools/block-kit-builder?mode=modal&view=%7B%22type%22%3A%22modal%22%2C%22title%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Quick%20survey%22%2C%22emoji%22%3Atrue%7D%2C%22submit%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Submit%22%2C%22emoji%22%3Atrue%7D%2C%22blocks%22%3A%5B%7B%22type%22%3A%22input%22%2C%22block_id%22%3A%22foods%22%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22What%20do%20you%20want%20to%20eat%20for%20the%20party%20in%20this%20Friday%3F%22%2C%22emoji%22%3Atrue%7D%2C%22optional%22%3Afalse%2C%22element%22%3A%7B%22type%22%3A%22checkboxes%22%2C%22action_id%22%3A%22foods%22%2C%22options%22%3A%5B%7B%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Burger%20%3Ahamburger%3A%22%2C%22verbatim%22%3Atrue%7D%2C%22value%22%3A%22burger%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Pizza%20%3Apizza%3A%22%2C%22verbatim%22%3Atrue%7D%2C%22value%22%3A%22pizza%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Tex-Mex%20taco%20%3Ataco%3A%22%2C%22verbatim%22%3Atrue%7D%2C%22value%22%3A%22taco%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Sushi%20%3Asushi%3A%22%2C%22verbatim%22%3Atrue%7D%2C%22value%22%3A%22sushi%22%7D%2C%7B%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22Others%22%2C%22verbatim%22%3Atrue%7D%2C%22value%22%3A%22others%22%2C%22description%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22_Let%20me%20know%20in%20the%20below%20form._%22%2C%22verbatim%22%3Atrue%7D%7D%5D%7D%7D%2C%7B%22type%22%3A%22input%22%2C%22block_id%22%3A%22others%22%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22What%20do%20you%20want%3F%22%2C%22emoji%22%3Atrue%7D%2C%22optional%22%3Atrue%2C%22element%22%3A%7B%22type%22%3A%22plain_text_input%22%2C%22action_id%22%3A%22others%22%7D%7D%5D%7D) + +##### Props for modal's input + +- `label` (**required**): The label string for the group. +- `id` / `blockId` (optional): A string of unique identifier of [`` layout block](layout-blocks.md#input). +- `title`/ `hint` (optional): Specify a helpful text appears under the group. +- `required` (optional): A boolean prop to specify whether any value must be filled when user confirms modal. + +### ``: Checkbox + +A checkbox item. It must place in the children of ``. + +It supports raw [mrkdwn format](https://api.slack.com/reference/surfaces/formatting) / [HTML-like formatting](./html-like-formatting.md) in the both of contents and `description` property. + +```jsx + + XXX-1234 - by Yuki Hattori + + } +> + Checkbox item: foobar + +``` + +[](https://api.slack.com/tools/block-kit-builder?mode=appHome&view=%7B%22type%22%3A%22home%22%2C%22blocks%22%3A%5B%7B%22type%22%3A%22actions%22%2C%22elements%22%3A%5B%7B%22type%22%3A%22checkboxes%22%2C%22options%22%3A%5B%7B%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22*Checkbox%20item*%3A%20foobar%22%2C%22verbatim%22%3Atrue%7D%2C%22value%22%3A%22checkbox%22%2C%22description%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22XXX-1234%20-%20_by%20Yuki%20Hattori_%22%2C%22verbatim%22%3Atrue%7D%7D%5D%7D%5D%7D%5D%7D) + +> :information_source: [Links and mentions through `` tag](https://github.com/speee/jsx-slack/blob/master/docs/html-like-formatting.md#links) will be ignored by Slack. + +#### Props + +- `value` (**required**): A string value to send to Slack App when choosing the checkbox. +- `description` (optional): A description string or JSX element for the current checkbox. It can see with faded color just below the main label. `` prefers this prop than redirection by ``. +- `checked` (optional): A boolean value indicating the initial state of the checkbox. If it's not defined, the initial state is following `values` property in the parent ``. + +#### Redirect `` into description + +`` allows `` element for ergonomic templating, to redirect the content into description when `description` prop is not defined. + +A below checkbox is meaning exactly the same as an example shown earlier. + +```jsx + + Checkbox item: foobar + + XXX-1234 - by Yuki Hattori + + +``` + ### [``: Radio button group](https://api.slack.com/reference/block-kit/block-elements#radio) (Only for modal and home tab) A container for grouping radio buttons. It provides easy control of the selected option through similar interface to [``: Plain-text input element](https://api.slack.com/reference/block-kit/block-elements#input) diff --git a/docs/html-like-formatting.md b/docs/html-like-formatting.md index d3d50b1c..6879d608 100644 --- a/docs/html-like-formatting.md +++ b/docs/html-like-formatting.md @@ -8,7 +8,7 @@ jsx-slack has HTML-compatible JSX elements to format messages. It might be verbo _Using HTML elements is not mandatory. You may also use [a regular mrkdwn syntax][mrkdwn] to format if necessary._ -[mrkdwn]: https://api.slack.com/docs/message-formatting +[mrkdwn]: https://api.slack.com/reference/surfaces/formatting ## Format text style diff --git a/docs/jsx-components-for-block-kit.md b/docs/jsx-components-for-block-kit.md index fb838b5f..abedfcdd 100644 --- a/docs/jsx-components-for-block-kit.md +++ b/docs/jsx-components-for-block-kit.md @@ -35,6 +35,8 @@ - [**``**: Overflow menu](block-elements.md#overflow) - [**``**: Menu item in overflow menu](block-elements.md#overflow-item) - [**``**: Select date from calendar](block-elements.md#date-picker) +- [**``**: Checkbox group](block-elements.md#checkbox-group) (Only for modal and home tab) + - [**``**: Checkbox](block-elements.md#checkbox) - [**``**: Radio button group](block-elements.md#radio-button-group) (Only for modal and home tab) - [**``**: Radio button](block-elements.md#radio-button) diff --git a/docs/layout-blocks.md b/docs/layout-blocks.md index d0bb4cea..e7414427 100644 --- a/docs/layout-blocks.md +++ b/docs/layout-blocks.md @@ -28,7 +28,7 @@ Display a simple text message. You have to specify the content as children. It a ### Accessory -A one of accessory component may include as the children of `
`. The defined element will show in side-by-side of text. +A one of accessory component may include as the children of `
`. The defined element will show in side-by-side or just below of text. ```jsx @@ -52,6 +52,7 @@ A one of accessory component may include as the children of `
`. The def - [``](block-elements.md#channels-select) - [``](block-elements.md#overflow) - [``](block-elements.md#date-picker) +- [``](block-elements.md#checkbox-group) (Only for [``](block-containers.md#modal) and [``](block-containers.md#home) container) - [``](block-elements.md#radio-button-group) (Only for [``](block-containers.md#modal) and [``](block-containers.md#home) container) ### ``: Fields for section block @@ -218,6 +219,7 @@ If you want to use `` as layout block, you have to place one of [availabl - [``](block-elements.md#conversations-select) - [``](block-elements.md#channels-select) - [``](block-elements.md#date-picker) +- [``](block-elements.md#checkbox-group) - [``](block-elements.md#radio-button-group) ### Note diff --git a/src/block-kit/Actions.tsx b/src/block-kit/Actions.tsx index fc42e0a2..5adefa21 100644 --- a/src/block-kit/Actions.tsx +++ b/src/block-kit/Actions.tsx @@ -16,14 +16,15 @@ interface ActionsProps extends BlockComponentProps { export const actionTypes = [ 'button', - 'static_select', - 'external_select', - 'users_select', - 'conversations_select', 'channels_select', - 'overflow', + 'checkboxes', + 'conversations_select', 'datepicker', + 'external_select', + 'overflow', 'radio_buttons', + 'static_select', + 'users_select', ] as const export const Actions: JSXSlack.FC = props => { diff --git a/src/block-kit/Blocks.tsx b/src/block-kit/Blocks.tsx index 81787b0e..e5887dd9 100644 --- a/src/block-kit/Blocks.tsx +++ b/src/block-kit/Blocks.tsx @@ -31,14 +31,14 @@ const knownSectionAccessories: KnownMap = new Map() const basicBlocks = ['actions', 'context', 'divider', 'image', 'section'] // Blocks +const actionTypeFilterForMessaging = (t: string) => + !['checkboxes', 'radio_buttons'].includes(t) + knownBlocks.set(undefined, [...basicBlocks, 'file']) -knownActions.set( - undefined, - actionTypes.filter(t => t !== 'radio_buttons') -) +knownActions.set(undefined, actionTypes.filter(actionTypeFilterForMessaging)) knownSectionAccessories.set( undefined, - sectionAccessoryTypes.filter(t => t !== 'radio_buttons') + sectionAccessoryTypes.filter(actionTypeFilterForMessaging) ) // Modal diff --git a/src/block-kit/Context.tsx b/src/block-kit/Context.tsx index a3dc4003..0336174b 100644 --- a/src/block-kit/Context.tsx +++ b/src/block-kit/Context.tsx @@ -45,7 +45,7 @@ export const Context: JSXSlack.FC component if (props.type === mrkdwnSymbol) - return mrkdwn(props.text, props.verbatim) + return mrkdwn(html(props.children), props.verbatim) } return undefined diff --git a/src/block-kit/Input.tsx b/src/block-kit/Input.tsx index 1664061a..e9d9c8d4 100644 --- a/src/block-kit/Input.tsx +++ b/src/block-kit/Input.tsx @@ -70,6 +70,7 @@ export interface InternalSubmitObject { const knownInputs = [ 'channels_select', + 'checkboxes', 'conversations_select', 'datepicker', 'external_select', diff --git a/src/block-kit/Section.tsx b/src/block-kit/Section.tsx index 9acaf142..60d095f1 100644 --- a/src/block-kit/Section.tsx +++ b/src/block-kit/Section.tsx @@ -19,21 +19,22 @@ interface FieldInternalObject { } export const sectionAccessoryTypes = [ - 'image', 'button', - 'static_select', - 'external_select', - 'users_select', - 'conversations_select', 'channels_select', - 'multi_static_select', + 'checkboxes', + 'conversations_select', + 'datepicker', + 'external_select', + 'image', + 'multi_channels_select', + 'multi_conversations_select', 'multi_external_select', + 'multi_static_select', 'multi_users_select', - 'multi_conversations_select', - 'multi_channels_select', 'overflow', - 'datepicker', 'radio_buttons', + 'static_select', + 'users_select', ] as const export const Section: JSXSlack.FC has unexpected component as an accessory.') } diff --git a/src/block-kit/composition/Mrkdwn.tsx b/src/block-kit/composition/Mrkdwn.tsx index 4fe89bde..fad5f837 100644 --- a/src/block-kit/composition/Mrkdwn.tsx +++ b/src/block-kit/composition/Mrkdwn.tsx @@ -1,7 +1,6 @@ /** @jsx JSXSlack.h */ import { JSXSlack } from '../../jsx' import { ObjectOutput } from '../../utils' -import html from '../../html' export const mrkdwnSymbol = Symbol('jsx-slack-mrkdwn-composition') @@ -10,16 +9,10 @@ interface MrkdwnProps { verbatim?: boolean } -interface MrkdwnComponentProps { +interface MrkdwnInternalProps extends MrkdwnProps { type: typeof mrkdwnSymbol - text: string - verbatim?: boolean } -export const Mrkdwn: JSXSlack.FC = ({ children, verbatim }) => ( - - type={mrkdwnSymbol} - text={html(children)} - verbatim={verbatim} - /> +export const Mrkdwn: JSXSlack.FC = props => ( + {...props} type={mrkdwnSymbol} /> ) diff --git a/src/block-kit/composition/utils.ts b/src/block-kit/composition/utils.ts index 4fc8aa4a..e2286592 100644 --- a/src/block-kit/composition/utils.ts +++ b/src/block-kit/composition/utils.ts @@ -21,11 +21,16 @@ export const mrkdwn = ( verbatim, }) -export const mrkdwnFromNode = (node: JSXSlack.Children<{}>): MrkdwnElement => { +export const mrkdwnFromNode = ( + node: JSXSlack.Children<{}>, + defaultOptions?: { + verbatim?: boolean + } +): MrkdwnElement => { const [child] = JSXSlack.normalizeChildren(node) if (typeof child === 'object' && child.props.type === mrkdwnSymbol) - return mrkdwn(child.props.text, child.props.verbatim) + return mrkdwn(html(child.props.children), child.props.verbatim) - return mrkdwn(html(node), true) + return mrkdwn(html(node), defaultOptions ? defaultOptions.verbatim : true) } diff --git a/src/block-kit/elements/Checkbox.tsx b/src/block-kit/elements/Checkbox.tsx new file mode 100644 index 00000000..baa3a639 --- /dev/null +++ b/src/block-kit/elements/Checkbox.tsx @@ -0,0 +1,104 @@ +/** @jsx JSXSlack.h */ +import { PlainTextElement, MrkdwnElement } from '@slack/types' +import { findNode, pickInternalNodes } from './utils' +import { ConfirmProps } from '../composition/Confirm' +import { mrkdwnFromNode } from '../composition/utils' +import { JSXSlack } from '../../jsx' +import { ObjectOutput, isNode } from '../../utils' +import { WithInputProps, wrapInInput } from '../Input' + +const checkboxInternal = Symbol('checkboxInternal') + +interface CheckboxOption { + text: MrkdwnElement // jsx-slack alaways uses mrkdwn element + value?: string + description?: MrkdwnElement +} + +interface CheckboxGroupPropsBase { + actionId?: string + children?: JSXSlack.Children + confirm?: JSXSlack.Node + name?: string + values?: string[] +} + +export type CheckboxGroupProps = WithInputProps + +export interface CheckboxProps { + checked?: boolean + children: JSXSlack.Children<{}> + description?: JSXSlack.Children<{}> + value: string +} + +interface CheckboxInternalProps extends CheckboxProps { + type: typeof checkboxInternal +} + +const toOptionObject = (props: CheckboxInternalProps): CheckboxOption => { + let description: JSXSlack.Children<{}> | undefined + const small = findNode(props.children, ({ type }) => type === 'small') + + if (small) { + description = small.children + small.children = [] + } + + const option: CheckboxOption = { + text: mrkdwnFromNode(props.children), + value: props.value, + } + + description = props.description || description + + if (description) + option.description = mrkdwnFromNode(description, { + verbatim: option.text.verbatim, + }) + + return option +} + +export const CheckboxGroup: JSXSlack.FC = props => { + const states = new Map() + const values = props.values || [] + + const options = pickInternalNodes( + checkboxInternal, + props.children as JSXSlack.Children + ).map(({ props: cProps }) => { + if (cProps.value) { + if (cProps.checked !== undefined) { + states.set(cProps.value, !!cProps.checked) + } else if (values.includes(cProps.value)) { + states.set(cProps.value, true) + } + } + return toOptionObject(cProps) + }) + + if (options.length === 0) + throw new Error(' must include least of one .') + + const initialOptions = options.reduce( + (arr, opt) => (opt.value && states.get(opt.value) ? [...arr, opt] : arr), + [] as CheckboxOption[] + ) + + const element = ( + 0 ? initialOptions : undefined} + confirm={props.confirm ? JSXSlack(props.confirm) : undefined} + /> + ) + + return props.label ? wrapInInput(element, props) : element +} + +export const Checkbox: JSXSlack.FC = props => ( + {...props} type={checkboxInternal} /> +) diff --git a/src/block-kit/elements/RadioButton.tsx b/src/block-kit/elements/RadioButton.tsx index fb1b4348..3b7c2db9 100644 --- a/src/block-kit/elements/RadioButton.tsx +++ b/src/block-kit/elements/RadioButton.tsx @@ -1,16 +1,17 @@ /** @jsx JSXSlack.h */ -import { Option } from '@slack/types' +import { Option, RadioButtons } from '@slack/types' +import { findNode, pickInternalNodes } from './utils' import { ConfirmProps } from '../composition/Confirm' import { plainText } from '../composition/utils' import { JSXSlack } from '../../jsx' -import { ObjectOutput, PlainText, isNode } from '../../utils' +import { ObjectOutput, PlainText } from '../../utils' import { WithInputProps, wrapInInput } from '../Input' const radioButtonInternal = Symbol('radioButtonInternal') interface RadioButtonGroupPropsBase { actionId?: string - children?: JSXSlack.Children + children?: JSXSlack.Children confirm?: JSXSlack.Node name?: string value?: string @@ -24,34 +25,35 @@ export interface RadioButtonProps { value: string } -interface RadioButtonInternal extends RadioButtonProps { - label: string +interface RadioButtonInternalProps extends RadioButtonProps { type: typeof radioButtonInternal } -const filterRadioButtons = children => - JSXSlack.normalizeChildren(children).filter( - c => - isNode(c) && - c.type === JSXSlack.NodeType.object && - c.props.type === radioButtonInternal - ) as JSXSlack.Node[] +const toOptionObject = (props: RadioButtonInternalProps): Option => { + let description: string | undefined + const small = findNode(props.children, ({ type }) => type === 'small') + + if (small) { + description = JSXSlack() + small.children = [] + } -const toOptionObject = (props: RadioButtonInternal): Option => { const option: Option = { - text: plainText(props.label), + text: plainText(JSXSlack(<PlainText children={props.children} />)), value: props.value, } - if (props.description) option.description = plainText(props.description) + description = props.description || description + if (description) option.description = plainText(description) return option } export const RadioButtonGroup: JSXSlack.FC<RadioButtonGroupProps> = props => { - const options = filterRadioButtons(props.children).map(radio => - toOptionObject(radio.props) - ) + const options = pickInternalNodes<RadioButtonInternalProps>( + radioButtonInternal, + props.children as JSXSlack.Children<RadioButtonInternalProps> + ).map(radio => toOptionObject(radio.props)) if (options.length === 0) throw new Error( @@ -63,7 +65,7 @@ export const RadioButtonGroup: JSXSlack.FC<RadioButtonGroupProps> = props => { : undefined const element = ( - <ObjectOutput + <ObjectOutput<RadioButtons> type="radio_buttons" action_id={props.actionId || props.name} options={options} @@ -76,9 +78,8 @@ export const RadioButtonGroup: JSXSlack.FC<RadioButtonGroupProps> = props => { } export const RadioButton: JSXSlack.FC<RadioButtonProps> = props => ( - <ObjectOutput<RadioButtonInternal> + <ObjectOutput<RadioButtonInternalProps> {...props} type={radioButtonInternal} - label={JSXSlack(<PlainText>{props.children}</PlainText>)} /> ) diff --git a/src/block-kit/elements/utils.ts b/src/block-kit/elements/utils.ts new file mode 100644 index 00000000..19cbf4ef --- /dev/null +++ b/src/block-kit/elements/utils.ts @@ -0,0 +1,31 @@ +import { JSXSlack } from '../../jsx' +import { isNode } from '../../utils' + +export const findNode = ( + nodes: JSXSlack.Children<{}>, + callback: (node: JSXSlack.Node) => boolean +): JSXSlack.Node | undefined => { + for (const node of JSXSlack.normalizeChildren(nodes)) { + if (isNode(node)) { + if (callback(node)) return node + + let ret = findNode(node.props.children, callback) + if (ret) return ret + + ret = findNode(node.children, callback) + if (ret) return ret + } + } + return undefined +} + +export const pickInternalNodes = <P extends { type: symbol }>( + symbol: P['type'], + children: JSXSlack.Children<P> +): JSXSlack.Node<P>[] => + JSXSlack.normalizeChildren(children).filter( + (c): c is JSXSlack.Node<P> => + isNode(c) && + c.type === JSXSlack.NodeType.object && + c.props.type === symbol + ) diff --git a/src/block-kit/index.ts b/src/block-kit/index.ts index ecf77e28..ae3e0cf3 100644 --- a/src/block-kit/index.ts +++ b/src/block-kit/index.ts @@ -11,10 +11,13 @@ export { File } from './File' export { Image } from './Image' export { Input, Textarea } from './Input' export { Section, Field } from './Section' -export { Mrkdwn } from './composition/Mrkdwn' // Block elements export { Button } from './elements/Button' +export { CheckboxGroup, Checkbox } from './elements/Checkbox' +export { DatePicker } from './elements/DatePicker' +export { Overflow, OverflowItem } from './elements/Overflow' +export { RadioButtonGroup, RadioButton } from './elements/RadioButton' export { Select, SelectFragment, @@ -25,12 +28,10 @@ export { ConversationsSelect, ChannelsSelect, } from './elements/Select' -export { Overflow, OverflowItem } from './elements/Overflow' -export { DatePicker } from './elements/DatePicker' -export { RadioButtonGroup, RadioButton } from './elements/RadioButton' // PlainTextInput won't provide because Input block has an usage as component. // export { PlainTextInput } from './elements/PlainTextInput' // Composition objects export { Confirm } from './composition/Confirm' +export { Mrkdwn } from './composition/Mrkdwn' diff --git a/src/html.tsx b/src/html.tsx index 99191f01..b20ab2b0 100644 --- a/src/html.tsx +++ b/src/html.tsx @@ -129,6 +129,7 @@ export const parse = ( return `<time${attrs}>${format}</time>` } + case 'small': case 'span': case 'ul': case 'li': diff --git a/src/jsx.ts b/src/jsx.ts index 5566c343..03c2c526 100644 --- a/src/jsx.ts +++ b/src/jsx.ts @@ -165,6 +165,7 @@ export namespace JSXSlack { s: {} section: { id?: string; children: Children<any> } select: IntrinsicProps<SelectProps> + small: {} span: {} strike: {} strong: {} diff --git a/src/utils.tsx b/src/utils.tsx index 494e6615..f20be916 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -21,11 +21,9 @@ export enum SpecialLink { const spLinkMatcher = /^(#C|@[SUW])[A-Z0-9]{8}$/ -export function ArrayOutput<P = any>(props: { +export const ArrayOutput = <P extends {} = any>(props: { children: JSXSlack.Children<P> -}) { - return JSXSlack.h(JSXSlack.NodeType.array, props) -} +}) => JSXSlack.h(JSXSlack.NodeType.array, props) export const ObjectOutput = <P extends {} = any>(props: P) => JSXSlack.h(JSXSlack.NodeType.object, props) @@ -46,13 +44,12 @@ export function wrap<T>(children: T | T[]): T[] { return [] } -export const aliasTo = (component: JSXSlack.FC<any>, node: JSXSlack.Node) => { - return JSXSlack.h( +export const aliasTo = (component: JSXSlack.FC<any>, node: JSXSlack.Node) => + JSXSlack.h( component, node.props, ...wrap(node.props.children || node.children) ) -} export function detectSpecialLink(href: string): SpecialLink | undefined { if (href === '@channel') return SpecialLink.ChannelMention diff --git a/test/block-kit/block-elements/input-components-for-modal.tsx b/test/block-kit/block-elements/input-components-for-modal.tsx index 71308dad..2bc4858e 100644 --- a/test/block-kit/block-elements/input-components-for-modal.tsx +++ b/test/block-kit/block-elements/input-components-for-modal.tsx @@ -2,6 +2,8 @@ import { InputBlock, View } from '@slack/types' import JSXSlack, { ChannelsSelect, + CheckboxGroup, + Checkbox, ConversationsSelect, DatePicker, ExternalSelect, @@ -35,6 +37,11 @@ describe('Input components for modal', () => { <RadioButton value="test">test</RadioButton> </RadioButtonGroup> ), + props => ( + <CheckboxGroup {...props}> + <Checkbox value="test">test</Checkbox> + </CheckboxGroup> + ), ]) { expect( JSXSlack( diff --git a/test/block-kit/block-elements/interactive-components.tsx b/test/block-kit/block-elements/interactive-components.tsx index 42a58c2f..fc301c9c 100644 --- a/test/block-kit/block-elements/interactive-components.tsx +++ b/test/block-kit/block-elements/interactive-components.tsx @@ -3,6 +3,7 @@ import { ActionsBlock, Option as SlackOption, Overflow as SlackOverflow, + RadioButtons, SectionBlock, } from '@slack/types' import JSXSlack, { @@ -10,12 +11,16 @@ import JSXSlack, { Blocks, Button, ChannelsSelect, + CheckboxGroup, + Checkbox, Confirm, ConversationsSelect, DatePicker, ExternalSelect, + Fragment, Home, Modal, + Mrkdwn, Optgroup, Option, Overflow, @@ -647,7 +652,7 @@ describe('Interactive components', () => { describe('<RadioButtonGroup>', () => { it('outputs radio button group in actions block', () => { - const radioButtonAction = { + const radioButtonAction: RadioButtons = { type: 'radio_buttons', action_id: 'radio-buttons', options: [ @@ -717,11 +722,13 @@ describe('Interactive components', () => { </Confirm> } > - <RadioButton value="first" description="The first option"> + <RadioButton value="first"> 1st + <small>The first option</small> </RadioButton> - <RadioButton value="second" description="The second option"> + <RadioButton value="second"> 2nd + <small>The second option</small> </RadioButton> <RadioButton value="third">3rd</RadioButton> </RadioButtonGroup> @@ -808,4 +815,288 @@ describe('Interactive components', () => { ).toThrow(/incompatible/i) }) }) + + describe('<CheckboxGroup>', () => { + it('outputs checkbox group in actions block', () => { + const checkboxAction = { + type: 'checkboxes', + action_id: 'checkboxGroup', + options: [ + { + text: { type: 'mrkdwn', text: '*1st*', verbatim: true }, + description: { + type: 'mrkdwn', + text: 'The first option', + verbatim: true, + }, + value: 'first', + }, + { + text: { type: 'mrkdwn', text: '2nd', verbatim: true }, + description: { + type: 'mrkdwn', + text: 'The _second_ option', + verbatim: true, + }, + value: 'second', + }, + { + text: { type: 'mrkdwn', text: '3rd', verbatim: true }, + value: 'third', + }, + ], + initial_options: [ + { + text: { type: 'mrkdwn', text: '2nd', verbatim: true }, + description: { + type: 'mrkdwn', + text: 'The _second_ option', + verbatim: true, + }, + value: 'second', + }, + ], + } + + expect( + JSXSlack( + <Home> + <Actions blockId="actions"> + <CheckboxGroup actionId="checkboxGroup" values={['second']}> + <Checkbox value="first" description="The first option"> + <strong>1st</strong> + </Checkbox> + <Checkbox + value="second" + description={ + <Fragment> + The <i>second</i> option + </Fragment> + } + > + 2nd + </Checkbox> + <Checkbox value="third">3rd</Checkbox> + </CheckboxGroup> + </Actions> + </Home> + ).blocks + ).toStrictEqual([action(checkboxAction)]) + + // Alternative ways + expect( + JSXSlack( + <Home> + <Actions id="actions"> + <CheckboxGroup name="checkboxGroup"> + <Checkbox value="first"> + *1st* + <small>The first option</small> + </Checkbox> + <Checkbox + value="second" + description={['The ', <i>second</i>, ' option']} + checked + > + 2nd + </Checkbox> + <Checkbox value="third">3rd</Checkbox> + </CheckboxGroup> + </Actions> + </Home> + ).blocks + ).toStrictEqual([action(checkboxAction)]) + + // confirm prop in <Modal> + expect( + JSXSlack( + <Modal title="modal"> + <Actions blockId="actions"> + <CheckboxGroup + actionId="checkboxGroup" + confirm={ + <Confirm title="a" confirm="b" deny="c"> + foobar + </Confirm> + } + > + <Checkbox value="first"> + <Mrkdwn verbatim> + <b>1st</b> + <small>The first option</small> + </Mrkdwn> + </Checkbox> + <Checkbox value="second" checked> + 2nd + <small> + <Mrkdwn verbatim> + The <i>second</i> option + </Mrkdwn> + </small> + </Checkbox> + <Checkbox value="third">3rd</Checkbox> + </CheckboxGroup> + </Actions> + </Modal> + ).blocks + ).toStrictEqual([ + action({ + ...checkboxAction, + confirm: { + title: { type: 'plain_text', text: 'a', emoji: true }, + confirm: { type: 'plain_text', text: 'b', emoji: true }, + deny: { type: 'plain_text', text: 'c', emoji: true }, + text: { type: 'mrkdwn', text: 'foobar', verbatim: true }, + }, + } as any), + ]) + }) + + it('outputs checkbox group in section block', () => { + const [section]: SectionBlock[] = JSXSlack( + <Home> + <Section> + test + <CheckboxGroup> + <Checkbox value="a">A</Checkbox> + </CheckboxGroup> + </Section> + </Home> + ).blocks + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(section.accessory!.type).toBe('checkboxes') + }) + + it('throws error when <CheckboxGroup> has not contained <Checkbox>', () => { + expect(() => + JSXSlack( + <Home> + <Actions> + <CheckboxGroup>{}</CheckboxGroup> + </Actions> + </Home> + ) + ).toThrow(/must include/i) + + expect(() => + JSXSlack( + <Home> + <Actions> + <CheckboxGroup> + <Option value="wtf">I'm not checkbox</Option> + </CheckboxGroup> + </Actions> + </Home> + ) + ).toThrow(/must include/i) + }) + + it('throws error when using <CheckboxGroup> within <Blocks> container', () => { + expect(() => + JSXSlack( + <Blocks> + <Section> + test + <CheckboxGroup> + <Checkbox value="a">A</Checkbox> + </CheckboxGroup> + </Section> + </Blocks> + ) + ).toThrow(/incompatible/i) + + expect(() => + JSXSlack( + <Blocks> + <Actions> + <CheckboxGroup> + <Checkbox value="a">A</Checkbox> + </CheckboxGroup> + </Actions> + </Blocks> + ) + ).toThrow(/incompatible/i) + }) + + it('prefers description prop of <Checkbox> rather than the content in <small> element', () => { + const [section] = JSXSlack( + <Home> + <Section> + test + <CheckboxGroup> + <Checkbox value="hello" description="foo"> + Hello! + <small>bar</small> + </Checkbox> + </CheckboxGroup> + </Section> + </Home> + ).blocks + + const [option] = section.accessory.options + expect(option.description.text).toBe('foo') + }) + + it("inherits content's <Mrkdwn> option into description", () => { + const [section] = JSXSlack( + <Home> + <Section> + test + <CheckboxGroup> + <Checkbox value="regular" description="description"> + Content + </Checkbox> + <Checkbox value="inherited" description="description"> + <Mrkdwn verbatim={false}>Content</Mrkdwn> + </Checkbox> + <Checkbox + value="mixed" + description={<Mrkdwn verbatim={false}>description</Mrkdwn>} + > + <Mrkdwn>Content</Mrkdwn> + </Checkbox> + <Checkbox value="small-mixed"> + <Mrkdwn>Content</Mrkdwn> + <small> + <Mrkdwn verbatim={false}>description</Mrkdwn> + </small> + </Checkbox> + </CheckboxGroup> + </Section> + </Home> + ).blocks + + const [regular, inherited, mixed, smallMixed] = section.accessory.options + expect(regular.description.verbatim).toBe(true) + expect(inherited.description.verbatim).toBe(false) + expect(mixed.text.verbatim).toBeUndefined() + expect(mixed.description.verbatim).toBe(false) + expect(smallMixed.text.verbatim).toBeUndefined() + expect(smallMixed.description.verbatim).toBe(false) + }) + }) + + it('prefers checked attribute in <Checkbox> rather than value prop in <CheckboxGroup>', () => { + const [section] = JSXSlack( + <Home> + <Section> + test + <CheckboxGroup values={['b', 'd']}> + <Checkbox value="a">A</Checkbox> + <Checkbox value="b">B</Checkbox> + <Checkbox value="c" checked={true}> + C + </Checkbox> + <Checkbox value="d" checked={false}> + D + </Checkbox> + </CheckboxGroup> + </Section> + </Home> + ).blocks + + const values = section.accessory.initial_options.map(opt => opt.value) + expect(values).toStrictEqual(['b', 'c']) + }) }) diff --git a/test/index.tsx b/test/index.tsx index 4d83c0a3..a927df7a 100644 --- a/test/index.tsx +++ b/test/index.tsx @@ -6,4 +6,9 @@ beforeEach(() => JSXSlack.exactMode(false)) describe('#JSXSlack', () => { it('throws error by passed invalid node', () => expect(() => JSXSlack({ props: {}, type: -1 } as any)).toThrow()) + + it('throws error when using not supported HTML element in JSX', () => + expect(() => + JSXSlack({ props: {}, type: 'center', children: [] }) + ).toThrow()) }) diff --git a/test/tag.tsx b/test/tag.tsx index 4d7899d5..2b0e388f 100644 --- a/test/tag.tsx +++ b/test/tag.tsx @@ -6,10 +6,10 @@ import JSXSlack, { Divider, Fragment, Image, - jsxslack, - Option as BlockKitOption, + Option, Section, - Select as BlockKitSelect, + Select, + jsxslack, } from '../src/index' describe('Tagged template', () => { @@ -49,11 +49,11 @@ describe('Tagged template', () => { <Divider /> <Actions> <Button actionId={`clap${count}`}>:clap: {count}</Button> - <BlockKitSelect actionId="select"> - <BlockKitOption value="1">one</BlockKitOption> - <BlockKitOption value="2">two</BlockKitOption> - <BlockKitOption value="3">three</BlockKitOption> - </BlockKitSelect> + <Select actionId="select"> + <Option value="1">one</Option> + <Option value="2">two</Option> + <Option value="3">three</Option> + </Select> </Actions> </Blocks> ) @@ -78,11 +78,11 @@ describe('Tagged template', () => { JSXSlack( <Blocks> <Actions> - <BlockKitSelect> + <Select> {[...Array(10)].map((_, i) => ( - <BlockKitOption value={i.toString()}>{i}</BlockKitOption> + <Option value={i.toString()}>{i}</Option> ))} - </BlockKitSelect> + </Select> </Actions> </Blocks> )