Repository: speee/jsx-slack
Pull request #9 (2019-03-11 10:10:33)

Add SelectFragment component
`<SelectFragment>` component will generate a simple object from `<Option>` or `<Optgroups>`.

```jsx
<SelectFragment>
  <Option value="item">Item</Option>
</SelectFragment>
```

```json
{
  "options": [
    {
      "value": "item",
      "text": {
        "text": "Item",
        "type": "plain_text",
        "emoji": true
      }
    }
  ]
}
```

It means jsx-slack becomes to be able to generate [options for external source](https://api.slack.com/reference/messaging/block-elements#select_menu_with_external_data_source). I also updated `README.md` to add [usage of `<SelectFragment>` with express](https://github.com/speee/jsx-slack/blob/c906c27143978c939772bd93c7fbf436af3d431b/README.md#selectfragment-generate-options-for-external-source).

This PR includes refactoring `<Select>` by using `<SelectFragment>` internally.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9abb66..e38d8e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### Added
+
+- Add `<SelectFragment>` component ([#9](https://github.com/speee/jsx-slack/pull/9))
+
 ### Changed
 
 - Right-aligned number in ordered list ([#8](https://github.com/speee/jsx-slack/pull/8))
diff --git a/README.md b/README.md
index c1ab448..f603457 100644
--- a/README.md
+++ b/README.md
@@ -397,6 +397,31 @@ It requires setup JSON entry URL in your Slack app. [Learn about external source
 - `minQueryLength` (optional): A length of typed characters to begin JSON request.
 - `confirm` (optional): [`<Confirm>` element](#confirm-confirmation-dialog) to show confirmation dialog.
 
+##### `<SelectFragment>`: Generate options for external source
+
+You would want to build not only the message but also the data source by jsx-slack. `<SelectFragment>` component can create JSON object for external data source usable in `<ExternalSelect>`.
+
+A following is a simple example to serve JSON for external select via [express](https://expressjs.com/). It is using [`jsxslack` tagged template literal](#template-literal).
+
+```javascript
+import { jsxslack } from '@speee-js/jsx-slack'
+import express from 'express'
+
+const app = express()
+
+app.get('/external-data-source', (req, res) => {
+  res.json(jsxslack`
+    <SelectFragment>
+      <Option value="item-a">Item A</Option>
+      <Option value="item-b">Item B</Option>
+      <Option value="item-c">Item C</Option>
+    </SelectFragment>
+  `)
+})
+
+app.listen(80)
+```
+
 #### [`<UsersSelect>`: Select menu with user list](https://api.slack.com/reference/messaging/block-elements#users-select)
 
 A select menu with options consisted of users in the current workspace.
diff --git a/src/block-kit/index.ts b/src/block-kit/index.ts
index 48039b4..f701275 100644
--- a/src/block-kit/index.ts
+++ b/src/block-kit/index.ts
@@ -12,6 +12,7 @@ export { Section, Field } from './Section'
 export { Button } from './interactive/Button'
 export {
   Select,
+  SelectFragment,
   Option,
   Optgroup,
   ExternalSelect,
diff --git a/src/block-kit/interactive/Select.tsx b/src/block-kit/interactive/Select.tsx
index 04b2703..e9c3b51 100644
--- a/src/block-kit/interactive/Select.tsx
+++ b/src/block-kit/interactive/Select.tsx
@@ -7,6 +7,7 @@ import {
   Option as SlackOption,
   UsersSelect as SlackUsersSelect,
 } from '@slack/client'
+import flatten from 'lodash.flatten'
 import { ConfirmProps } from '../composition/Confirm'
 import { JSXSlack } from '../../jsx'
 import { ObjectOutput, PlainText } from '../../utils'
@@ -17,11 +18,14 @@ export interface SelectPropsBase {
   confirm?: JSXSlack.Node<ConfirmProps>
 }
 
-interface SelectProps extends SelectPropsBase {
-  value?: string
+interface SelectFragmentProps {
   children: JSXSlack.Children<OptionInternal | OptgroupInternal>
 }
 
+interface SelectProps extends SelectPropsBase, SelectFragmentProps {
+  value?: string
+}
+
 interface ExternalSelectProps extends SelectPropsBase {
   initialOption?: JSXSlack.Node<OptionInternal> | SlackOption
   minQueryLength?: number
@@ -62,6 +66,10 @@ interface OptgroupInternal extends OptgroupProps {
   type: 'optgroup'
 }
 
+type SelectFragmentObject<T extends 'options' | 'option_groups'> = Required<
+  Pick<StaticSelect, T>
+>
+
 const baseProps = ({
   actionId,
   placeholder,
@@ -91,66 +99,68 @@ const filter = <T extends {}>(children: JSXSlack.Children<T>) =>
     o => typeof o !== 'string'
   ) as JSXSlack.Node<T>[]
 
-export const Select: JSXSlack.FC<SelectProps> = props => {
+export const SelectFragment: JSXSlack.FC<SelectFragmentProps> = props => {
   const opts = filter(props.children)
 
   if (opts.length === 0)
     throw new Error(
-      '<Select> must include least of one <Option> or <Optgroup>.'
+      'Component for selection must include least of one <Option> or <Optgroup>.'
     )
 
   const { type } = opts[0].props
   if (!opts.every(o => o.props.type === type))
     throw new Error(
-      '<Select> must only include either of <Option> and <Optgroup>.'
+      'Component for selection must only include either of <Option> and <Optgroup>.'
     )
 
-  let initialOption: SlackOption | undefined
-
-  const createAndMatchOption = (cmProps: OptionInternal) => {
-    const opt: SlackOption = createOption(cmProps)
-
-    if (typeof props.value === 'string' && opt.value === props.value) {
-      initialOption = opt
-    }
-
-    return opt
-  }
-
-  const rest: Pick<StaticSelect, 'options' | 'option_groups'> = {}
-
-  // TODO: Create `options` / `option_groups` prop by <SelectFragment> component
-  // to support generating external data source with jsx-slack.
   switch (type) {
     case 'option':
-      rest.options = (opts as JSXSlack.Node<OptionInternal>[]).map(n =>
-        createAndMatchOption(n.props)
+      return (
+        <ObjectOutput<SelectFragmentObject<'options'>>
+          options={(opts as JSXSlack.Node<OptionInternal>[]).map(n =>
+            createOption(n.props)
+          )}
+        />
       )
-      break
     case 'optgroup':
-      rest.option_groups = (opts as JSXSlack.Node<OptgroupInternal>[]).map(
-        n => ({
-          label: {
-            type: 'plain_text',
-            text: n.props.label,
-            emoji: true, // TODO: Controlable emoji
-          },
-          options: filter(n.props.children).map(o =>
-            createAndMatchOption(o.props)
-          ),
-        })
-      ) as any
-      break
+      return (
+        <ObjectOutput<SelectFragmentObject<'option_groups'>>
+          option_groups={
+            (opts as JSXSlack.Node<OptgroupInternal>[]).map(n => ({
+              label: {
+                type: 'plain_text',
+                text: n.props.label,
+                emoji: true, // TODO: Controlable emoji
+              },
+              options: filter(n.props.children).map(o => createOption(o.props)),
+            })) as any
+          }
+        />
+      )
     default:
       throw new Error(`Unexpected option type: ${type}`)
   }
+}
+
+export const Select: JSXSlack.FC<SelectProps> = props => {
+  const fragment = JSXSlack(<SelectFragment children={props.children} />)
+
+  // Find initial option
+  let initialOption: SlackOption | undefined
+
+  if (typeof props.value === 'string') {
+    const opts: SlackOption[] =
+      fragment.options || flatten(fragment.option_groups.map(og => og.options))
+
+    initialOption = opts.find(o => o.value === props.value)
+  }
 
   return (
     <ObjectOutput<StaticSelect>
       type="static_select"
       {...baseProps(props)}
       initial_option={initialOption}
-      {...rest}
+      {...fragment}
     />
   )
 }


Test patch:
diff --git a/test/index.tsx b/test/index.tsx
index 18f34b3..e2972ca 100644
--- a/test/index.tsx
+++ b/test/index.tsx
@@ -4,6 +4,7 @@ import {
   DividerBlock,
   ImageBlock,
   SectionBlock,
+  StaticSelect,
   Option as SlackOption,
 } from '@slack/client'
 import JSXSlack, {
@@ -26,6 +27,7 @@ import JSXSlack, {
   OverflowItem,
   Section,
   Select,
+  SelectFragment,
   UsersSelect,
 } from '../src/index'
 
@@ -782,4 +784,81 @@ describe('jsx-slack', () => {
         }),
       ]))
   })
+
+  describe('<SelectFragment> component', () => {
+    it('allows building object for external data source of <ExternalSelect>', () => {
+      const expectedOptions: Required<Pick<StaticSelect, 'options'>> = {
+        options: [
+          {
+            text: { type: 'plain_text', text: 'A', emoji: true },
+            value: 'a',
+          },
+          {
+            text: { type: 'plain_text', text: 'B', emoji: true },
+            value: 'b',
+          },
+          {
+            text: { type: 'plain_text', text: 'C', emoji: true },
+            value: 'c',
+          },
+        ],
+      }
+
+      expect(
+        JSXSlack(
+          <SelectFragment>
+            <Option value="a">A</Option>
+            <Option value="b">B</Option>
+            <Option value="c">C</Option>
+          </SelectFragment>
+        )
+      ).toStrictEqual(expectedOptions)
+
+      const expectedOptgroups: Required<Pick<StaticSelect, 'option_groups'>> = {
+        option_groups: [
+          {
+            label: { type: 'plain_text', text: 'A', emoji: true },
+            options: [
+              {
+                text: { type: 'plain_text', text: 'one', emoji: true },
+                value: '1',
+              },
+              {
+                text: { type: 'plain_text', text: 'two', emoji: true },
+                value: '2',
+              },
+            ],
+          },
+          {
+            label: { type: 'plain_text', text: 'B', emoji: true },
+            options: [
+              {
+                text: { type: 'plain_text', text: 'three', emoji: true },
+                value: '3',
+              },
+              {
+                text: { type: 'plain_text', text: 'four', emoji: true },
+                value: '4',
+              },
+            ],
+          },
+        ],
+      }
+
+      expect(
+        JSXSlack(
+          <SelectFragment>
+            <Optgroup label="A">
+              <Option value="1">one</Option>
+              <Option value="2">two</Option>
+            </Optgroup>
+            <Optgroup label="B">
+              <Option value="3">three</Option>
+              <Option value="4">four</Option>
+            </Optgroup>
+          </SelectFragment>
+        )
+      ).toStrictEqual(expectedOptgroups)
+    })
+  })
 })