Repository: speee/jsx-slack (ts)

Issue:
Support for Dispatch Action Config for Plain Text Input
As seen here: https://api.slack.com/reference/block-kit/block-elements#input

Pull request:
Configurable dispatchAction prop for <Input type="text"> and <Textarea>
The configurable dispatch action is the lack part of `dispatchAction` prop added in #200. `<Input type="text">` and `<Textarea>` can define space-separated specific string or its array to `dispatchAction` prop to assign `dispatch_action_config` composition object into the `plain_text` element.

```jsx
<Home>
  <Input label="Incremental search" dispatchAction="onEnterPressed onCharacterEntered" />
</Home>
```

```json
{
  "type": "home",
  "blocks": [
    {
      "type": "input",
      "label": {
        "type": "plain_text",
        "text": "Incremental search",
        "emoji": true
      },
      "optional": true,
      "dispatch_action": true,
      "element": {
        "type": "plain_text_input",
        "dispatch_action_config": {
          "trigger_actions_on": [
            "on_enter_pressed",
            "on_character_entered"
          ]
        }
      }
    }
  ]
}
```

In other input components, `dispatchAction` prop accepts only boolean as same as before.

Even if in either of `<Input type="text">` or `<Textarea>`, the dispatch action composition object would not set into the plain-text element when defined unknown string. However, the parent `input` layout block will have `dispatch_action` as `true` if the unknown value is equivalent with truthy.

Closes #204.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4cf553..974aa09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
 
 ## [Unreleased]
 
+### Added
+
+- [Configurable `dispatchAction` prop](https://github.com/speee/jsx-slack/blob/master/docs/block-elements.md#input) for `<Input type="text">` and `<Textarea>` (equivalent to [`dispatch_action_config` for the plain-text input](https://api.slack.com/reference/block-kit/block-elements#input)) ([#204](https://github.com/speee/jsx-slack/issues/204), [#205](https://github.com/speee/jsx-slack/pull/205))
+
 ## v2.5.1 - 2020-10-08
 
 ### Added
diff --git a/demo/schema.js b/demo/schema.js
index 5b56742..96c4f34 100644
--- a/demo/schema.js
+++ b/demo/schema.js
@@ -198,6 +198,7 @@ const schema = {
       value: null,
       maxLength: null,
       minLength: null,
+      dispatchAction: ['onCharacterEntered', 'onEnterPressed'],
     },
     children: [
       'Select',
@@ -220,6 +221,7 @@ const schema = {
       value: null,
       maxLength: null,
       minLength: null,
+      dispatchAction: ['onCharacterEntered', 'onEnterPressed'],
     },
     children: [
       'Select',
@@ -408,6 +410,7 @@ const schema = {
       value: null,
       maxLength: null,
       minLength: null,
+      dispatchAction: ['onCharacterEntered', 'onEnterPressed'],
     },
     children: [],
   },
@@ -419,6 +422,7 @@ const schema = {
       value: null,
       maxLength: null,
       minLength: null,
+      dispatchAction: ['onCharacterEntered', 'onEnterPressed'],
     },
     children: [],
   },
diff --git a/docs/block-elements.md b/docs/block-elements.md
index 8ec3364..054a188 100644
--- a/docs/block-elements.md
+++ b/docs/block-elements.md
@@ -826,6 +826,7 @@ The list of input components is following:
 - [`<ConversationsSelect>`](#conversations-select)
 - [`<ChannelsSelect>`](#channels-select)
 - [`<DatePicker>`](#date-picker)
+- [`<TimePicker>`](#time-picker)
 - [`<CheckboxGroup>`](#checkbox-group)
 - [`<RadioButtonGroup>`](#radio-button-group)
 
@@ -852,7 +853,9 @@ It has an interface similar to `<input>` HTML element and `<input>` intrinsic HT
 - `title`/ `hint` (optional): Specify a helpful text appears under the element.
 - `placeholder` (optional): Specify a text string appears within the content of input is empty. (150 characters maximum)
 - `required` (optional): A boolean prop to specify whether any value must be filled when user confirms modal.
-- `dispatchAction` (optional): By setting `true`, the input element will dispatch [`block_actions` payload](https://api.slack.com/reference/interaction-payloads/block-actions) when used this.
+- `dispatchAction` (optional): By setting `true`, the input element will dispatch [`block_actions` payload](https://api.slack.com/reference/interaction-payloads/block-actions) when used this. By defining interaction type(s) as space-separated string or array, [you can determine when `<Input>` will return the payload.](https://api.slack.com/reference/block-kit/composition-objects#dispatch_action_config)
+  - `onEnterPressed`: Payload is dispatched when hitting Enter key while focusing to the input component.
+  - `onCharacterEntered`: Payload is dispatched when changing input characters.
 - `value` (optional): An initial value for plain-text input.
 - `maxLength` (optional): The maximum number of characters allowed for the input element. It must up to 3000 character.
 - `minLength` (optional): The minimum number of characters allowed for the input element.
diff --git a/src/block-kit/composition/utils.ts b/src/block-kit/composition/utils.ts
index 5a94265..482d4d9 100644
--- a/src/block-kit/composition/utils.ts
+++ b/src/block-kit/composition/utils.ts
@@ -1,14 +1,24 @@
 import { ConversationsSelect, PlainTextElement } from '@slack/types'
 import { JSXSlack } from '../../jsx'
 
+type DispatchActionConfigTriggerActionsOn =
+  | 'onEnterPressed'
+  | 'onCharacterEntered'
+
 type FilterComposition = ConversationsSelect['filter']
 type FilterIncludeKind = 'im' | 'mpim' | 'private' | 'public'
 
 // It's a workaround for working auto-completion of string literals in IDE while
 // still allowing space-separated string. This hack has called `LiteralUnion`.
 // @see https://github.com/Microsoft/TypeScript/issues/29729
-declare const spaceSeparatedKind: unique symbol
-type SpaceSeparatedKind = string & { [spaceSeparatedKind]?: never }
+declare const spaceSeparatedDispatchActionConfigTriggerActionsOn: unique symbol
+type SpaceSeparatedDispatchActionConfigTriggerActionsOn = string & {
+  [spaceSeparatedDispatchActionConfigTriggerActionsOn]?: never
+}
+declare const spaceSeparatedFilterIncludeKind: unique symbol
+type SpaceSeparatedFilterIncludeKind = string & {
+  [spaceSeparatedFilterIncludeKind]?: never
+}
 
 export interface FilterProps {
   /**
@@ -22,7 +32,10 @@ export interface FilterProps {
    *
    * By default, all conversation types are included.
    */
-  include?: FilterIncludeKind | FilterIncludeKind[] | SpaceSeparatedKind
+  include?:
+    | FilterIncludeKind
+    | FilterIncludeKind[]
+    | SpaceSeparatedFilterIncludeKind
 
   /**
    * A boolean value whether to exclude external
@@ -35,6 +48,30 @@ export interface FilterProps {
   excludeBotUsers?: boolean
 }
 
+export interface DispatchActionConfigComposition {
+  trigger_actions_on?: ('on_enter_pressed' | 'on_character_entered')[]
+}
+
+export interface InputDispatchActionProps {
+  /**
+   * @doc-plain-text-input
+   * If defined interaction type(s) as space-separated string or array, you can
+   * determine when the plain-text input component will return the payload, as
+   * same as
+   * {@link https://api.slack.com/reference/block-kit/composition-objects#dispatch_action_config defining `dispatch_action_config` in Slack API}.
+   *
+   * - `onEnterPressed`: Payload is dispatched when hitting Enter key while
+   *   focusing to the input component.
+   * - `onCharacterEntered`: Payload is dispatched when changing input
+   *   characters.
+   */
+  dispatchAction?:
+    | boolean
+    | DispatchActionConfigTriggerActionsOn
+    | DispatchActionConfigTriggerActionsOn[]
+    | SpaceSeparatedDispatchActionConfigTriggerActionsOn
+}
+
 // Text composition object for plain text
 const renderAsPlainText = (
   children: JSXSlack.ChildElements,
@@ -96,3 +133,38 @@ export const filter = (props: FilterProps): FilterComposition => {
     ? filterComposition
     : undefined
 }
+
+// Dispatch action configuration composition object
+export const inputDispatchActionConfig = (
+  props: InputDispatchActionProps
+): DispatchActionConfigComposition | undefined => {
+  const dispatchActionConfigComposition: DispatchActionConfigComposition = {}
+  let { dispatchAction } = props
+
+  if (typeof dispatchAction !== 'boolean' && dispatchAction) {
+    if (!Array.isArray(dispatchAction)) {
+      dispatchAction = dispatchAction.split(' ') as any[]
+    }
+
+    const triggerActionsOn = [
+      ...new Set(
+        dispatchAction
+          .filter((a): a is DispatchActionConfigTriggerActionsOn =>
+            ['onEnterPressed', 'onCharacterEntered'].includes(a)
+          )
+          .map((a) => {
+            if (a === 'onEnterPressed') return 'on_enter_pressed'
+            return 'on_character_entered'
+          })
+      ),
+    ]
+
+    if (triggerActionsOn.length > 0) {
+      dispatchActionConfigComposition.trigger_actions_on = triggerActionsOn
+    }
+  }
+
+  return Object.keys(dispatchActionConfigComposition).length > 0
+    ? dispatchActionConfigComposition
+    : undefined
+}
diff --git a/src/block-kit/elements/PlainTextInput.ts b/src/block-kit/elements/PlainTextInput.ts
index b3f3e7e..0761c0b 100644
--- a/src/block-kit/elements/PlainTextInput.ts
+++ b/src/block-kit/elements/PlainTextInput.ts
@@ -1,6 +1,9 @@
 import { PlainTextInput as SlackPlainTextInput } from '@slack/types'
 import { createComponent } from '../../jsx'
-import { plainText } from '../composition/utils'
+import {
+  plainText,
+  DispatchActionConfigComposition,
+} from '../composition/utils'
 
 export interface PlainTextInputProps {
   children?: never
@@ -10,12 +13,15 @@ export interface PlainTextInputProps {
   minLength?: number
   multiline?: boolean
   placeholder?: string
+  dispatchActionConfig?: DispatchActionConfigComposition
 }
 
 // NOTE: <PlainTextInput> is not public component
 export const PlainTextInput = createComponent<
   PlainTextInputProps,
-  SlackPlainTextInput
+  SlackPlainTextInput & {
+    dispatch_action_config?: DispatchActionConfigComposition
+  }
 >('PlainTextInput', (props) => ({
   type: 'plain_text_input',
   action_id: props.actionId,
@@ -28,4 +34,5 @@ export const PlainTextInput = createComponent<
   multiline: props.multiline,
   max_length: props.maxLength,
   min_length: props.minLength,
+  dispatch_action_config: props.dispatchActionConfig,
 }))
diff --git a/src/block-kit/input/Textarea.tsx b/src/block-kit/input/Textarea.tsx
index 05e4ff6..090863e 100644
--- a/src/block-kit/input/Textarea.tsx
+++ b/src/block-kit/input/Textarea.tsx
@@ -2,6 +2,7 @@
 import { InputBlock, PlainTextInput as SlackPlainTextInput } from '@slack/types'
 import { cleanMeta, createComponent, createElementInternal } from '../../jsx'
 import { coerceToInteger } from '../../utils'
+import { inputDispatchActionConfig } from '../composition/utils'
 import { PlainTextInput } from '../elements/PlainTextInput'
 import { InputTextProps, wrapInInput } from '../layout/Input'
 
@@ -43,9 +44,16 @@ export const Textarea = createComponent<TextareaProps, InputBlock>(
           minLength={coerceToInteger(props.minLength)}
           placeholder={props.placeholder}
           multiline={true}
+          dispatchActionConfig={inputDispatchActionConfig(props)}
         />
       ) as SlackPlainTextInput,
-      props,
+      {
+        ...props,
+        dispatchAction:
+          props.dispatchAction === undefined
+            ? undefined
+            : !!props.dispatchAction,
+      },
       Textarea
     )
 )
diff --git a/src/block-kit/layout/Input.tsx b/src/block-kit/layout/Input.tsx
index f4fdba1..8d88e5a 100644
--- a/src/block-kit/layout/Input.tsx
+++ b/src/block-kit/layout/Input.tsx
@@ -9,7 +9,11 @@ import {
   BuiltInComponent,
 } from '../../jsx'
 import { DistributedProps, coerceToInteger } from '../../utils'
-import { plainText } from '../composition/utils'
+import {
+  plainText,
+  inputDispatchActionConfig,
+  InputDispatchActionProps,
+} from '../composition/utils'
 import { PlainTextInput } from '../elements/PlainTextInput'
 import { ActionProps } from '../elements/utils'
 import { resolveTagName } from '../utils'
@@ -97,7 +101,10 @@ interface InputComponentBaseProps extends Omit<InputLayoutProps, 'children'> {
   required?: boolean
 }
 
-export interface InputTextProps extends InputComponentBaseProps, ActionProps {
+export interface InputTextProps
+  extends Omit<InputComponentBaseProps, 'dispatchAction'>,
+    ActionProps,
+    InputDispatchActionProps {
   children?: never
 
   /**
@@ -373,9 +380,14 @@ export const Input: BuiltInComponent<InputProps> = createComponent<
           maxLength={coerceToInteger(props.maxLength)}
           minLength={coerceToInteger(props.minLength)}
           placeholder={props.placeholder}
+          dispatchActionConfig={inputDispatchActionConfig(props)}
         />
       ),
-    props,
+    {
+      ...props,
+      dispatchAction:
+        props.dispatchAction === undefined ? undefined : !!props.dispatchAction,
+    },
     Input
   )
 })


Test patch:
diff --git a/test/block-kit/block-elements/input-components.tsx b/test/block-kit/block-elements/input-components.tsx
index 47bcae0..45e2d56 100644
--- a/test/block-kit/block-elements/input-components.tsx
+++ b/test/block-kit/block-elements/input-components.tsx
@@ -128,18 +128,6 @@ describe('Input components', () => {
         ).blocks
       ).toStrictEqual([expected]))
 
-    it('accepts dispatchAction prop', () => {
-      expect(<Input label="input" />).not.toHaveProperty('dispatch_action')
-      expect(<Input label="input" dispatchAction />).toHaveProperty(
-        'dispatch_action',
-        true
-      )
-      expect(<Input label="input" dispatchAction={false} />).toHaveProperty(
-        'dispatch_action',
-        false
-      )
-    })
-
     it('allows using HTML-compatible <input> element', () =>
       expect(
         JSXSlack(
@@ -155,6 +143,71 @@ describe('Input components', () => {
         ).blocks
       ).toStrictEqual([expected]))
 
+    describe('dispatchAction prop', () => {
+      it('accepts dispatchAction prop as boolean', () => {
+        expect(<Input label="input" />).not.toHaveProperty('dispatch_action')
+        expect(<Input label="input" dispatchAction />).toHaveProperty(
+          'dispatch_action',
+          true
+        )
+        expect(<Input label="input" dispatchAction />).not.toHaveProperty(
+          'element.dispatch_action_config'
+        )
+        expect(<Input label="input" dispatchAction={false} />).toHaveProperty(
+          'dispatch_action',
+          false
+        )
+      })
+
+      it('sets dispatch action config composition object if defined dispatchAction prop as specific string', () => {
+        const daOCE = (
+          <Input label="input" dispatchAction="onCharacterEntered" />
+        )
+        expect(daOCE).toHaveProperty('dispatch_action', true)
+        expect(daOCE).toHaveProperty('element.dispatch_action_config', {
+          trigger_actions_on: ['on_character_entered'],
+        })
+
+        const daOEP = <Input label="input" dispatchAction="onEnterPressed" />
+        expect(daOEP).toHaveProperty('dispatch_action', true)
+        expect(daOEP).toHaveProperty('element.dispatch_action_config', {
+          trigger_actions_on: ['on_enter_pressed'],
+        })
+
+        const daArray = (
+          <Input
+            label="input"
+            dispatchAction={['onEnterPressed', 'onCharacterEntered']}
+          />
+        )
+        expect(daArray).toHaveProperty('dispatch_action', true)
+        expect(daArray).toHaveProperty('element.dispatch_action_config', {
+          trigger_actions_on: ['on_enter_pressed', 'on_character_entered'],
+        })
+
+        const daSpaceSeparated = (
+          <Input
+            label="input"
+            dispatchAction=" onCharacterEntered unknown onEnterPressed onCharacterEntered"
+          />
+        )
+        expect(daSpaceSeparated).toHaveProperty('dispatch_action', true)
+        expect(daSpaceSeparated).toHaveProperty(
+          'element.dispatch_action_config',
+          {
+            // Remove duplicated / unknown actions
+            trigger_actions_on: ['on_character_entered', 'on_enter_pressed'],
+          }
+        )
+      })
+
+      it('does not set dispatch action config composition but enabled dispatch_action if passed unknown', () => {
+        const daUnknown = <Input label="input" dispatchAction="unknown" />
+        expect(daUnknown).toHaveProperty('dispatch_action', true)
+        expect(daUnknown).not.toHaveProperty('element.dispatch_action_config')
+      })
+    })
+
     it("marks placeholder's emoji flag as disabled", () => {
       const { blocks: blocksPlaceholder } = JSXSlack(
         <Modal title="test">
@@ -295,6 +348,74 @@ describe('Input components', () => {
 
       expect(blocks).toStrictEqual([expected])
     })
+
+    describe('dispatchAction prop', () => {
+      it('accepts dispatchAction prop as boolean', () => {
+        expect(<Textarea label="textarea" />).not.toHaveProperty(
+          'dispatch_action'
+        )
+        expect(<Textarea label="textarea" dispatchAction />).toHaveProperty(
+          'dispatch_action',
+          true
+        )
+        expect(<Textarea label="textarea" dispatchAction />).not.toHaveProperty(
+          'element.dispatch_action_config'
+        )
+        expect(
+          <Textarea label="textarea" dispatchAction={false} />
+        ).toHaveProperty('dispatch_action', false)
+      })
+
+      it('sets dispatch action config composition object if defined dispatchAction prop as specific string', () => {
+        const daOCE = (
+          <Textarea label="textarea" dispatchAction="onCharacterEntered" />
+        )
+        expect(daOCE).toHaveProperty('dispatch_action', true)
+        expect(daOCE).toHaveProperty('element.dispatch_action_config', {
+          trigger_actions_on: ['on_character_entered'],
+        })
+
+        const daOEP = (
+          <Textarea label="textarea" dispatchAction="onEnterPressed" />
+        )
+        expect(daOEP).toHaveProperty('dispatch_action', true)
+        expect(daOEP).toHaveProperty('element.dispatch_action_config', {
+          trigger_actions_on: ['on_enter_pressed'],
+        })
+
+        const daArray = (
+          <Textarea
+            label="textarea"
+            dispatchAction={['onEnterPressed', 'onCharacterEntered']}
+          />
+        )
+        expect(daArray).toHaveProperty('dispatch_action', true)
+        expect(daArray).toHaveProperty('element.dispatch_action_config', {
+          trigger_actions_on: ['on_enter_pressed', 'on_character_entered'],
+        })
+
+        const daSpaceSeparated = (
+          <Textarea
+            label="textarea"
+            dispatchAction=" onCharacterEntered unknown onEnterPressed onCharacterEntered"
+          />
+        )
+        expect(daSpaceSeparated).toHaveProperty('dispatch_action', true)
+        expect(daSpaceSeparated).toHaveProperty(
+          'element.dispatch_action_config',
+          {
+            // Remove duplicated / unknown actions
+            trigger_actions_on: ['on_character_entered', 'on_enter_pressed'],
+          }
+        )
+      })
+
+      it('does not set dispatch action config composition but enabled dispatch_action if passed unknown', () => {
+        const daUnknown = <Textarea label="textarea" dispatchAction="unknown" />
+        expect(daUnknown).toHaveProperty('dispatch_action', true)
+        expect(daUnknown).not.toHaveProperty('element.dispatch_action_config')
+      })
+    })
   })
 
   describe('<ConversationsSelect>', () => {