Repository: speee/jsx-slack
Pull request #126 (2020-03-11 08:26:46)

Make JSX element for passing to Slack API serializable to JSON directly
By assigning `toJSON()` to `JSXSlack.JSX.Element` that was created from root elements for Slack API (`<Blocks>`, `<Modal>`, `<Home>`, and `<SelectFragment>`), they become able to pass JSX into Slack Node SDK directly because of calling `toJSON()` by `JSON.stringify()`.

We have known user sometimes confuse whether should use `JSXSlack()` wrapper. By this change, we can direct not to use `JSXSlack()` basically.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54354ad..4a43f84 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 ### Changed
 
 - Improve escaping special characters to keep original character as possible ([#124](https://github.com/speee/jsx-slack/issues/124), [#125](https://github.com/speee/jsx-slack/pull/125))
+- Make JSX element for passing to Slack API serializable to JSON directly ([#126](https://github.com/speee/jsx-slack/pull/126))
 
 ## v1.4.0 - 2020-03-06
 
diff --git a/README.md b/README.md
index 741658c..4a32cc3 100644
--- a/README.md
+++ b/README.md
@@ -84,14 +84,12 @@ export default function exampleBlock({ name }) {
 
 When you want to use jsx-slack with JSX transpiler (Babel / TypeScript), you have to set up to use imported our parser `JSXSlack.h`. Typically, we recommend to use pragma comment `/** @jsx JSXSlack.h */`.
 
-To create JSON from JSX, please wrap JSX by `JSXSlack()` function.
-
 ```jsx
 /** @jsx JSXSlack.h */
 import JSXSlack, { Blocks, Section } from '@speee-js/jsx-slack'
 
 export default function exampleBlock({ name }) {
-  return JSXSlack(
+  return (
     <Blocks>
       <Section>
         Hello, <b>{name}</b>!
@@ -103,6 +101,8 @@ export default function exampleBlock({ name }) {
 
 A prgama would work in Babel ([@babel/plugin-transform-react-jsx](https://babeljs.io/docs/en/babel-plugin-transform-react-jsx)) and [TypeScript with `--jsx react`](https://www.typescriptlang.org/docs/handbook/jsx.html#factory-functions). You can use jsx-slack in either one.
 
+> :information_source: A returned value from JSX is a virtual node prepared for JSON. The node of [block containers](./docs/block-containers.md) will be serialized to JSON on calling `JSON.stringify()`. You can also create JSON object from passed node explicitly by wrapping JSX by `JSXSlack(<Blocks>...</Blocks>)`.
+
 ### Use template in Slack API
 
 After than, just use created template in Slack API. We are using the official Node SDK [`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) in this example. [See also Slack guide.](https://slackapi.github.io/node-slack-sdk/web_api)
diff --git a/docs/block-containers.md b/docs/block-containers.md
index 1baca94..ca5588d 100644
--- a/docs/block-containers.md
+++ b/docs/block-containers.md
@@ -4,6 +4,8 @@
 
 [Slack provides multiple surfaces](https://api.slack.com/surfaces) to place Block Kit [layout blocks](layout-blocks.md). So you should choose the parent container component depending on purpose.
 
+Container JSX elements have implemented [`toJSON()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON_behavior) so they are serializable to JSON directly.
+
 ## <a name="blocks" id="blocks"></a> [`<Blocks>`: The basic container for messages](https://api.slack.com/surfaces/messages)
 
 A basic container component for Block Kit suited to [messages](https://api.slack.com/surfaces/messages). Wrap layout block components in `<Blocks>`.
@@ -18,7 +20,7 @@ const api = new WebClient(process.env.SLACK_TOKEN)
 
 api.chat.postMessage({
   channel: 'C1232456',
-  blocks: JSXSlack(
+  blocks: (
     <Blocks>
       <Section>Hello, world!</Section>
     </Blocks>
@@ -36,7 +38,7 @@ A generated object by `<Modal>` container should pass to a `view` field in [`vie
 api.views.open({
   // NOTE: trigger_id received from interaction is required.
   trigger_id: 'xxxxx.xxxxx.xxxxxxxxxxxx',
-  view: JSXSlack(
+  view: (
     <Modal title="My first modal">
       <Section>Hello, modal!</Section>
     </Modal>
@@ -51,7 +53,7 @@ In addition to [layout blocks](layout-blocks.md), `<Modal>` container can place
 import JSXSlack, { Modal, ConversationsSelect } from '@speee-js/jsx-slack'
 
 export default function shareModal(opts) {
-  return JSXSlack(
+  return (
     <Modal title="Share" close="Cancel">
       <img src={opts.url} alt="image" />
 
@@ -90,7 +92,7 @@ A generated object by `<Home>` container should pass to a view field in [`views.
 ```javascript
 api.views.publish({
   user_id: 'UXXXXXXXX',
-  view: JSXSlack(
+  view: (
     <Home>
       <Section>Welcome to my home!</Section>
     </Home>
diff --git a/docs/block-elements.md b/docs/block-elements.md
index 1935ae2..8b986bf 100644
--- a/docs/block-elements.md
+++ b/docs/block-elements.md
@@ -217,7 +217,11 @@ It requires setup JSON entry URL in your Slack app. [Learn about external source
 
 ### <a name="select-fragment" id="select-fragment"></a> `<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>`.
+You may think want to build also the data source through jsx-slack. `<SelectFragment>` component can create JSON object for external data source usable in `<ExternalSelect>`.
+
+`<SelectFragment>` JSX element is serializable to JSON directly as same as [container components](./block-containers.md).
+
+#### Example
 
 A following is a super simple example to serve JSON for external select via [express](https://expressjs.com/). It is using [`jsxslack` tagged template literal](../README.md#quick-start-template-literal).
 
diff --git a/src/block-kit/Blocks.tsx b/src/block-kit/Blocks.tsx
index e5887dd..a28b832 100644
--- a/src/block-kit/Blocks.tsx
+++ b/src/block-kit/Blocks.tsx
@@ -1,6 +1,6 @@
 /** @jsx JSXSlack.h */
 import { JSXSlack } from '../jsx'
-import { ArrayOutput, aliasTo, isNode } from '../utils'
+import { ArrayOutput, aliasTo, isNode, makeSerializableToJSON } from '../utils'
 import { actionTypes } from './Actions'
 import { internalHiddenType, internalSubmitType } from './Input'
 import { sectionAccessoryTypes } from './Section'
@@ -122,7 +122,7 @@ export const Blocks: JSXSlack.FC<BlocksProps> = props => {
       )
   }
 
-  return <ArrayOutput>{normalized}</ArrayOutput>
+  return makeSerializableToJSON(<ArrayOutput>{normalized}</ArrayOutput>)
 }
 
 export const BlocksInternal: JSXSlack.FC<BlocksProps & {
diff --git a/src/block-kit/Home.tsx b/src/block-kit/Home.tsx
index b5f78d0..3249951 100644
--- a/src/block-kit/Home.tsx
+++ b/src/block-kit/Home.tsx
@@ -1,7 +1,7 @@
 /** @jsx JSXSlack.h */
 import { View } from '@slack/types'
 import { JSXSlack } from '../jsx'
-import { ObjectOutput } from '../utils'
+import { ObjectOutput, makeSerializableToJSON } from '../utils'
 import {
   BlockComponentProps,
   BlocksInternal,
@@ -16,17 +16,18 @@ export interface HomeProps {
   privateMetadata?: string
 }
 
-export const Home: JSXSlack.FC<HomeProps> = props => (
-  <ObjectOutput<View & { external_id?: string }>
-    type="home"
-    callback_id={props.callbackId}
-    external_id={props.externalId}
-    private_metadata={props.privateMetadata}
-    blocks={JSXSlack(
-      <BlocksInternal
-        {...{ [blockTypeSymbol]: InternalBlockType.Home }}
-        children={props.children}
-      />
-    )}
-  />
-)
+export const Home: JSXSlack.FC<HomeProps> = props =>
+  makeSerializableToJSON(
+    <ObjectOutput<View & { external_id?: string }>
+      type="home"
+      callback_id={props.callbackId}
+      external_id={props.externalId}
+      private_metadata={props.privateMetadata}
+      blocks={JSXSlack(
+        <BlocksInternal
+          {...{ [blockTypeSymbol]: InternalBlockType.Home }}
+          children={props.children}
+        />
+      )}
+    />
+  )
diff --git a/src/block-kit/Modal.tsx b/src/block-kit/Modal.tsx
index f5086d4..33b4c62 100644
--- a/src/block-kit/Modal.tsx
+++ b/src/block-kit/Modal.tsx
@@ -1,7 +1,7 @@
 /** @jsx JSXSlack.h */
 import { View } from '@slack/types'
 import { JSXSlack } from '../jsx'
-import { ObjectOutput } from '../utils'
+import { ObjectOutput, makeSerializableToJSON } from '../utils'
 import {
   BlocksInternal,
   BlockComponentProps,
@@ -65,7 +65,7 @@ export const Modal: JSXSlack.FC<ModalProps> = props => {
     return undefined
   })()
 
-  return (
+  return makeSerializableToJSON(
     <ObjectOutput<View & { external_id?: string }>
       type="modal"
       title={plainText(props.title)}
diff --git a/src/block-kit/elements/Select.tsx b/src/block-kit/elements/Select.tsx
index f01b366..f888732 100644
--- a/src/block-kit/elements/Select.tsx
+++ b/src/block-kit/elements/Select.tsx
@@ -26,6 +26,7 @@ import {
   coerceToInteger,
   flattenDeep,
   isNode,
+  makeSerializableToJSON,
   wrap,
 } from '../../utils'
 
@@ -219,42 +220,47 @@ const generateFragments = (
   return fragment
 }
 
-export const SelectFragment: JSXSlack.FC<SelectFragmentProps> = props => {
-  const opts = filter(props.children)
-
-  if (opts.length === 0)
-    return <ObjectOutput<SelectFragmentObject<'options'>> options={[]} />
-
-  const { type } = opts[0].props
-  if (!opts.every(o => o.props.type === type))
-    throw new Error(
-      'Component for selection must only include either of <Option> and <Optgroup>.'
-    )
-
-  switch (type) {
-    case 'option':
-      return (
-        <ObjectOutput<SelectFragmentObject<'options'>>
-          options={(opts as JSXSlack.Node<OptionInternal>[]).map(n =>
-            createOption(n.props)
-          )}
-        />
-      )
-    case 'optgroup':
-      return (
-        <ObjectOutput<SelectFragmentObject<'option_groups'>>
-          option_groups={
-            (opts as JSXSlack.Node<OptgroupInternal>[]).map(n => ({
-              label: plainText(n.props.label),
-              options: filter(n.props.children).map(o => createOption(o.props)),
-            })) as any
-          }
-        />
-      )
-    default:
-      throw new Error(`Unexpected option type: ${type}`)
-  }
-}
+export const SelectFragment: JSXSlack.FC<SelectFragmentProps> = props =>
+  makeSerializableToJSON(
+    (() => {
+      const opts = filter(props.children)
+
+      if (opts.length === 0)
+        return <ObjectOutput<SelectFragmentObject<'options'>> options={[]} />
+
+      const { type } = opts[0].props
+      if (!opts.every(o => o.props.type === type))
+        throw new Error(
+          'Component for selection must only include either of <Option> and <Optgroup>.'
+        )
+
+      switch (type) {
+        case 'option':
+          return (
+            <ObjectOutput<SelectFragmentObject<'options'>>
+              options={(opts as JSXSlack.Node<OptionInternal>[]).map(n =>
+                createOption(n.props)
+              )}
+            />
+          )
+        case 'optgroup':
+          return (
+            <ObjectOutput<SelectFragmentObject<'option_groups'>>
+              option_groups={
+                (opts as JSXSlack.Node<OptgroupInternal>[]).map(n => ({
+                  label: plainText(n.props.label),
+                  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 = generateFragments(props.children)
diff --git a/src/jsx.ts b/src/jsx.ts
index 3ecd0ca..e93a9fc 100644
--- a/src/jsx.ts
+++ b/src/jsx.ts
@@ -118,6 +118,7 @@ export namespace JSXSlack {
     type: FC<P> | string | NodeType
     props: Props<P>
     children: Child<any>[]
+    toJSON?: () => any
   }
 
   export const h = <P extends {}>(
diff --git a/src/utils.tsx b/src/utils.tsx
index 9c378fe..0fa78cb 100644
--- a/src/utils.tsx
+++ b/src/utils.tsx
@@ -66,6 +66,11 @@ export const aliasTo = (component: JSXSlack.FC<any>, node: JSXSlack.Node) =>
     ...wrap(node.props.children || node.children)
   )
 
+export const makeSerializableToJSON = (node: JSXSlack.Node): JSXSlack.Node => {
+  Object.defineProperty(node, 'toJSON', { value: () => JSXSlack(node) })
+  return node
+}
+
 export function detectSpecialLink(href: string): SpecialLink | undefined {
   if (href === '@channel') return SpecialLink.ChannelMention
   if (href === '@everyone') return SpecialLink.EveryoneMention


Test patch:
diff --git a/test/block-kit/builtin-components.tsx b/test/block-kit/builtin-components.tsx
index 8c7e8ca..7bde60c 100644
--- a/test/block-kit/builtin-components.tsx
+++ b/test/block-kit/builtin-components.tsx
@@ -399,5 +399,23 @@ describe('Built-in components', () => {
         options: [],
       })
     })
+
+    it('makes serializable to JSON without wrapping by JSXSlack()', () => {
+      expect(
+        JSON.stringify(
+          <SelectFragment>
+            <Option value="foo">Bar</Option>
+          </SelectFragment>
+        )
+      ).toBe(
+        JSON.stringify(
+          JSXSlack(
+            <SelectFragment>
+              <Option value="foo">Bar</Option>
+            </SelectFragment>
+          )
+        )
+      )
+    })
   })
 })
diff --git a/test/block-kit/container-components.tsx b/test/block-kit/container-components.tsx
index 7e85ef2..a595397 100644
--- a/test/block-kit/container-components.tsx
+++ b/test/block-kit/container-components.tsx
@@ -46,6 +46,24 @@ describe('Container components', () => {
         )
       ).toThrow()
     })
+
+    it('makes serializable to JSON without wrapping by JSXSlack()', () => {
+      expect(
+        JSON.stringify(
+          <Blocks>
+            <Section>Hello!</Section>
+          </Blocks>
+        )
+      ).toBe(
+        JSON.stringify(
+          JSXSlack(
+            <Blocks>
+              <Section>Hello!</Section>
+            </Blocks>
+          )
+        )
+      )
+    })
   })
 
   describe('<Modal>', () => {
@@ -106,6 +124,24 @@ describe('Container components', () => {
         )
       ).toThrow()
     })
+
+    it('makes serializable to JSON without wrapping by JSXSlack()', () => {
+      expect(
+        JSON.stringify(
+          <Modal title="test">
+            <Section>Hello!</Section>
+          </Modal>
+        )
+      ).toBe(
+        JSON.stringify(
+          JSXSlack(
+            <Modal title="test">
+              <Section>Hello!</Section>
+            </Modal>
+          )
+        )
+      )
+    })
   })
 
   describe('<Home>', () => {
@@ -143,36 +179,54 @@ describe('Container components', () => {
         )
       ).toStrictEqual(viewWithOptions)
     })
-  })
 
-  it('throws error when <Modal> has unexpected element', () => {
-    expect(() => JSXSlack(<Home>unexpected</Home>)).toThrow()
-    expect(() =>
-      JSXSlack(
-        <Home>
-          <b>unexpected</b>
-        </Home>
-      )
-    ).toThrow()
+    it('throws error when <Home> has unexpected element', () => {
+      expect(() => JSXSlack(<Home>unexpected</Home>)).toThrow()
+      expect(() =>
+        JSXSlack(
+          <Home>
+            <b>unexpected</b>
+          </Home>
+        )
+      ).toThrow()
 
-    expect(() =>
-      JSXSlack(
-        <Home>
-          <File externalId="external_id" />
-        </Home>
-      )
-    ).toThrow()
-
-    expect(() =>
-      JSXSlack(
-        <Home>
-          <Input label="Select">
-            <Select>
-              <Option value="test">test</Option>
-            </Select>
-          </Input>
-        </Home>
+      expect(() =>
+        JSXSlack(
+          <Home>
+            <File externalId="external_id" />
+          </Home>
+        )
+      ).toThrow()
+
+      expect(() =>
+        JSXSlack(
+          <Home>
+            <Input label="Select">
+              <Select>
+                <Option value="test">test</Option>
+              </Select>
+            </Input>
+          </Home>
+        )
+      ).toThrow()
+    })
+
+    it('makes serializable to JSON without wrapping by JSXSlack()', () => {
+      expect(
+        JSON.stringify(
+          <Home>
+            <Section>Hello!</Section>
+          </Home>
+        )
+      ).toBe(
+        JSON.stringify(
+          JSXSlack(
+            <Home>
+              <Section>Hello!</Section>
+            </Home>
+          )
+        )
       )
-    ).toThrow()
+    })
   })
 })