├── .eslintrc.js ├── .gitignore ├── .vscode └── launch.json ├── README.md ├── babel.config.js ├── docs └── slack-message.png ├── jest.config.js ├── package.json ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── renderer.test.tsx.snap │ ├── renderer.test.tsx │ └── utils.test.tsx ├── components │ ├── ActionsBlock.ts │ ├── AnyText.ts │ ├── Block.ts │ ├── BlockElement.ts │ ├── BlockQuote.ts │ ├── ButtonElement.ts │ ├── ChannelsSelect.ts │ ├── Checkbox.ts │ ├── Childless.ts │ ├── ContainerProps.ts │ ├── ContextBlock.ts │ ├── ConversationsSelect.ts │ ├── DividerBlock.ts │ ├── ExternalSelect.ts │ ├── HeaderBlock.ts │ ├── Home.ts │ ├── ImageBlock.ts │ ├── ImageElement.ts │ ├── InputBlock.ts │ ├── LineBreak.ts │ ├── Link.ts │ ├── MarkdownText.ts │ ├── Mention.ts │ ├── Message.ts │ ├── MessageText.ts │ ├── Modal.ts │ ├── MultiSelectElement.ts │ ├── OverflowMenuElement.ts │ ├── PlainText.ts │ ├── PlainTextInputElement.ts │ ├── ProgressBar.ts │ ├── RadioButtons.ts │ ├── SectionBlock.ts │ ├── SingleSelectElement.ts │ ├── Span.ts │ ├── Text.ts │ ├── TimePicker.ts │ ├── index.ts │ └── shared │ │ └── inputOption.ts ├── index.ts └── renderMarkdown.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'plugin:@typescript-eslint/recommended', 4 | 'prettier', 5 | 'prettier/@typescript-eslint', 6 | ], 7 | plugins: ['prettier'], 8 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 9 | parserOptions: { 10 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 11 | sourceType: 'module', // Allows for the use of imports 12 | ecmaFeatures: { 13 | jsx: true, // Allows for the parsing of JSX 14 | }, 15 | }, 16 | globals: { describe: true, it: true, expect: true }, 17 | rules: { 18 | '@typescript-eslint/no-angle-bracket-type-assertion': 'off', 19 | '@typescript-eslint/camelcase': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/no-explicit-any': 'off', 22 | '@typescript-eslint/ban-ts-ignore': 'off', 23 | '@typescript-eslint/no-unused-vars': 'off', 24 | '@typescript-eslint/no-namespace': 'off', 25 | quotes: [ 26 | 2, 27 | 'single', 28 | { 29 | avoidEscape: true, 30 | allowTemplateLiterals: true, 31 | }, 32 | ], 33 | 'prettier/prettier': [ 34 | 'error', 35 | { 36 | trailingComma: 'es5', 37 | singleQuote: true, 38 | printWidth: 80, 39 | arrowParens: 'avoid', 40 | }, 41 | ], 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .nyc_output 5 | *.lcov 6 | *.log 7 | package-lock.json 8 | .env 9 | .DS_Store 10 | lib 11 | index.d.ts -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/.bin/jest", 11 | "--runInBand", 12 | "--coverage", 13 | "false" 14 | ], 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen", 17 | "port": 9229 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-chat-renderer 2 | 3 | [![npm version](https://img.shields.io/npm/v/react-chat-renderer.svg?style=for-the-badge)](https://npmjs.org/package/react-chat-renderer 'View this project on npm') 4 | 5 | I wanted to build rich, interactive Slack and Discord workflows in a familiar idiom. Hence, a custom React renderer for declarative chat interactions. 6 | 7 | ## Design Principles 8 | 9 | - Support Slack's new [Block-kit](https://api.slack.com/block-kit) and [Interactive Messaging Workflows](https://api.slack.com/messaging/interactivity). 10 | - Attachments considered legacy/obsolete 11 | - Each Component is a pure function with a parent-agnostic view of a Slack message entity (eg. a [layout block](https://api.slack.com/reference/messaging/blocks)). It's responsible for `render`ing its own JSON shape. 12 | - these `FCs` should always return a JSON entity that is a subtree of a Slack message. 13 | - Should be able to build USEFUL, self-contained components that can do asynchronous things. Don't need a full-blown hooks implementation, but you [CAN make the JSX factory asynchronous](https://github.com/asynchronous-dev/react-chat-renderer/blob/master/src/__tests__/renderer.test.tsx#L330). 14 | 15 | ## Upcoming 16 | 17 | - Microsoft Teams support 18 | - more out-of-the-box elements 19 | 20 | ## Inspirations 21 | 22 | - [React-pdf](https://github.com/diegomura/react-pdf) 23 | - [ink](https://github.com/vadimdemedes/ink/blob/master/src/reconciler.js) 24 | - [react-ionize](https://github.com/mhink/react-ionize/blob/master/src/IonizeHostConfig.js) 25 | - [react-synth](https://github.com/FormidableLabs/react-synth) 26 | - [react-slack](https://github.com/andreyvital/react-slack-renderer/blob/master/components/SlackAttachment.js) 27 | - [jsx-slack](https://github.com/speee/jsx-slack/blob/master/src/jsx.ts#L146) 28 | 29 | ## Example 30 | 31 | ### Asynchronous components 32 | 33 | ```jsx 34 | /** @jsx slack.h */ 35 | /** @jsxFrag slack.Fragment */ 36 | import { 37 | slack, 38 | render, 39 | ContextBlock, 40 | ImageElement, 41 | PlainText, 42 | FC, 43 | } from 'react-chat-renderer'; 44 | 45 | const DeltaIndicator: FC<{delta: number}, any> = async ({ delta }) => { 46 | await fakePromise(); 47 | 48 | return delta > 0 ? ( 49 | 53 | ) : delta === 0 ? ( 54 | 'okay!' 55 | ) : ( 56 | 60 | ); 61 | }; 62 | 63 | it('renders contextblock with component children', async () => { 64 | const message = ( 65 | 66 | Hello, world</PlainText> 67 | <DeltaIndicator delta={-3} /> 68 | <DeltaIndicator delta={0} /> 69 | </ContextBlock> 70 | ); 71 | 72 | expect(await render(message)).toMatchSnapshot(); 73 | }); 74 | ``` 75 | 76 | ### JSX `Message` 77 | 78 | ````jsx 79 | /** @jsx slack.h */ 80 | import { 81 | slack, 82 | DividerBlock, 83 | SectionBlock, 84 | ButtonElement, 85 | PlainText, 86 | MarkdownText, 87 | ProgressBar, 88 | Message, 89 | } from '..'; 90 | 91 | const message = ( 92 | <Message responseType="in_channel"> 93 | <SectionBlock 94 | accessory={<ButtonElement actionId="doAThing">Go!</ButtonElement>} 95 | > 96 | <PlainText emoji>section text :sadkeanu:</PlainText> 97 | </SectionBlock> 98 | <DividerBlock /> 99 | <SectionBlock blockId="section1"> 100 | <MarkdownText> 101 | section ```code``` *progress:*{' '} 102 | <ProgressBar color="red" columnWidth="10" total="300" value="200" /> 103 | </MarkdownText> 104 | </SectionBlock> 105 | </Message> 106 | ); 107 | ```` 108 | 109 | ### Rendered JSON Message 110 | 111 | ![slack message](/docs/slack-message.png) 112 | 113 | ````json 114 | { 115 | "response_type": "in_channel", 116 | "as_user": false, 117 | "blocks": [ 118 | { 119 | "type": "section", 120 | "accessory": { 121 | "type": "button", 122 | "text": { 123 | "type": "plain_text", 124 | "emoji": true, 125 | "text": "Go!" 126 | }, 127 | "action_id": "doAThing" 128 | }, 129 | "text": { 130 | "type": "plain_text", 131 | "text": "section text :sadkeanu:", 132 | "emoji": true 133 | } 134 | }, 135 | { 136 | "type": "divider" 137 | }, 138 | { 139 | "type": "section", 140 | "text": { 141 | "type": "mrkdwn", 142 | "text": "section ```code``` *progress:* `▓▓▓▓▓▓▓░░░`", 143 | "verbatim": false 144 | }, 145 | "block_id": "section1" 146 | } 147 | ] 148 | } 149 | ```` 150 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/typescript', 4 | [ 5 | '@babel/env', 6 | { 7 | targets: { 8 | node: 'current', 9 | }, 10 | debug: false, 11 | }, 12 | ], 13 | ], 14 | plugins: [ 15 | [ 16 | '@wordpress/babel-plugin-import-jsx-pragma', 17 | { 18 | scopeVariable: 'slack', 19 | source: '..', 20 | isDefault: false, 21 | }, 22 | ], 23 | [ 24 | '@babel/transform-react-jsx', 25 | { 26 | pragma: 'slack.h', 27 | pragmaFrag: 'slack.Fragment', 28 | }, 29 | ], 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /docs/slack-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andycmaj/react-chat-renderer/cdca9c03a18c58abf258f2f21f9a603c2275c17f/docs/slack-message.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { jsWithTs } = require('ts-jest/presets'); 3 | 4 | module.exports = { 5 | collectCoverageFrom: ['**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx'], 6 | coveragePathIgnorePatterns: [ 7 | 'lib', 8 | '@types', 9 | '/node_modules/', 10 | '.*\\.d\\.ts', 11 | ], 12 | testEnvironment: 'node', 13 | testRegex: '.*(/(test|__tests__)/(?![_.]).*|(\\.|/)(test|spec))\\.[jt]sx?$', 14 | transform: { ...jsWithTs.transform }, 15 | globals: { 16 | 'ts-jest': { 17 | // ts-jest configuration goes here 18 | babelConfig: 'babel.config.js', 19 | }, 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-chat-renderer", 3 | "version": "0.6.14", 4 | "license": "MIT", 5 | "scripts": { 6 | "postversion": "npm publish", 7 | "preversion": "yarn test && yarn build", 8 | "test": "jest", 9 | "clean": "rm -rf dist", 10 | "build": "yarn clean && tsc --build tsconfig.build.json" 11 | }, 12 | "homepage": "https://github.com/asynchronous-dev/react-chat-renderer#readme", 13 | "bugs": { 14 | "url": "https://github.com/asynchronous-dev/react-chat-renderer/issues" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/asynchronous-dev/react-chat-renderer" 19 | }, 20 | "main": "dist/index.js", 21 | "types": "dist/index.d.ts", 22 | "files": [ 23 | "dist/**/*" 24 | ], 25 | "dependencies": { 26 | "@slack/types": "2.0.0", 27 | "lodash.flattendeep": "^4.4.0", 28 | "slackify-markdown": "4.0.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/cli": "7.10.3", 32 | "@babel/core": "7.10.3", 33 | "@babel/plugin-transform-react-jsx": "^7.10.3", 34 | "@babel/preset-env": "7.10.3", 35 | "@babel/preset-react": "7.10.1", 36 | "@babel/preset-typescript": "^7.10.1", 37 | "@slack/web-api": "^5.12.0", 38 | "@types/jest": "^26.0.3", 39 | "@types/lodash": "^4.14.157", 40 | "@typescript-eslint/eslint-plugin": "^3.4.0", 41 | "@typescript-eslint/parser": "^3.4.0", 42 | "@wordpress/babel-plugin-import-jsx-pragma": "^2.7.0", 43 | "babel-eslint": "^10.1.0", 44 | "eslint": "^7.3.1", 45 | "eslint-config-prettier": "^6.11.0", 46 | "eslint-plugin-prettier": "^3.1.4", 47 | "jest": "^26.1.0", 48 | "babel-jest": "^26.1.0", 49 | "prettier": "^2.0.5", 50 | "ts-jest": "^26.1.1", 51 | "typescript": "^3.9.5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/renderer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`slack jsx can ignore falsy children 1`] = ` 4 | Object { 5 | "blocks": Array [ 6 | Object { 7 | "type": "divider", 8 | }, 9 | Object { 10 | "type": "divider", 11 | }, 12 | Object { 13 | "type": "divider", 14 | }, 15 | ], 16 | "mrkdwn": true, 17 | "response_type": "in_channel", 18 | "text": "My weekly summary", 19 | } 20 | `; 21 | 22 | exports[`slack jsx can render nested fragments in maps 1`] = ` 23 | Object { 24 | "blocks": Array [ 25 | Object { 26 | "type": "divider", 27 | }, 28 | Object { 29 | "text": Object { 30 | "text": "My growth this week", 31 | "type": "mrkdwn", 32 | "verbatim": false, 33 | }, 34 | "type": "section", 35 | }, 36 | Object { 37 | "type": "divider", 38 | }, 39 | Object { 40 | "text": Object { 41 | "text": "*m1: uh1*", 42 | "type": "mrkdwn", 43 | "verbatim": false, 44 | }, 45 | "type": "section", 46 | }, 47 | Object { 48 | "elements": Array [ 49 | Object { 50 | "alt_text": "improved", 51 | "image_url": "https://user-images.githubusercontent.com/97470/75739421-a7138180-5cb9-11ea-9547-e64acf86eb59.png", 52 | "type": "image", 53 | }, 54 | Object { 55 | "alt_text": "declined", 56 | "image_url": "https://user-images.githubusercontent.com/97470/75739424-a7ac1800-5cb9-11ea-969a-e1ac9f12a41a.png", 57 | "type": "image", 58 | }, 59 | Object { 60 | "text": ":evergreen_tree: :small_red_triangle_down: stats go here", 61 | "type": "mrkdwn", 62 | "verbatim": false, 63 | }, 64 | ], 65 | "type": "context", 66 | }, 67 | Object { 68 | "text": Object { 69 | "text": "*m1: uh2*", 70 | "type": "mrkdwn", 71 | "verbatim": false, 72 | }, 73 | "type": "section", 74 | }, 75 | Object { 76 | "elements": Array [ 77 | Object { 78 | "alt_text": "improved", 79 | "image_url": "https://user-images.githubusercontent.com/97470/75739421-a7138180-5cb9-11ea-9547-e64acf86eb59.png", 80 | "type": "image", 81 | }, 82 | Object { 83 | "alt_text": "declined", 84 | "image_url": "https://user-images.githubusercontent.com/97470/75739424-a7ac1800-5cb9-11ea-969a-e1ac9f12a41a.png", 85 | "type": "image", 86 | }, 87 | Object { 88 | "text": ":evergreen_tree: :small_red_triangle_down: stats go here", 89 | "type": "mrkdwn", 90 | "verbatim": false, 91 | }, 92 | ], 93 | "type": "context", 94 | }, 95 | Object { 96 | "text": Object { 97 | "text": "*m1: uh3*", 98 | "type": "mrkdwn", 99 | "verbatim": false, 100 | }, 101 | "type": "section", 102 | }, 103 | Object { 104 | "elements": Array [ 105 | Object { 106 | "alt_text": "improved", 107 | "image_url": "https://user-images.githubusercontent.com/97470/75739421-a7138180-5cb9-11ea-9547-e64acf86eb59.png", 108 | "type": "image", 109 | }, 110 | Object { 111 | "alt_text": "declined", 112 | "image_url": "https://user-images.githubusercontent.com/97470/75739424-a7ac1800-5cb9-11ea-969a-e1ac9f12a41a.png", 113 | "type": "image", 114 | }, 115 | Object { 116 | "text": ":evergreen_tree: :small_red_triangle_down: stats go here", 117 | "type": "mrkdwn", 118 | "verbatim": false, 119 | }, 120 | ], 121 | "type": "context", 122 | }, 123 | Object { 124 | "text": Object { 125 | "text": "*m1: uh4*", 126 | "type": "mrkdwn", 127 | "verbatim": false, 128 | }, 129 | "type": "section", 130 | }, 131 | Object { 132 | "elements": Array [ 133 | Object { 134 | "alt_text": "improved", 135 | "image_url": "https://user-images.githubusercontent.com/97470/75739421-a7138180-5cb9-11ea-9547-e64acf86eb59.png", 136 | "type": "image", 137 | }, 138 | Object { 139 | "alt_text": "declined", 140 | "image_url": "https://user-images.githubusercontent.com/97470/75739424-a7ac1800-5cb9-11ea-969a-e1ac9f12a41a.png", 141 | "type": "image", 142 | }, 143 | Object { 144 | "text": ":evergreen_tree: :small_red_triangle_down: stats go here", 145 | "type": "mrkdwn", 146 | "verbatim": false, 147 | }, 148 | ], 149 | "type": "context", 150 | }, 151 | ], 152 | "mrkdwn": true, 153 | "response_type": "in_channel", 154 | "text": "My weekly summary", 155 | } 156 | `; 157 | 158 | exports[`slack jsx can render simple fragments 1`] = ` 159 | Object { 160 | "blocks": Array [ 161 | Object { 162 | "type": "divider", 163 | }, 164 | Object { 165 | "type": "divider", 166 | }, 167 | Object { 168 | "type": "divider", 169 | }, 170 | ], 171 | "mrkdwn": true, 172 | "response_type": "in_channel", 173 | "text": "My weekly summary", 174 | } 175 | `; 176 | 177 | exports[`slack jsx component with array of nested component children 1`] = ` 178 | Object { 179 | "blocks": Array [ 180 | Object { 181 | "text": Object { 182 | "emoji": true, 183 | "text": "section text :sadkeanu:", 184 | "type": "plain_text", 185 | }, 186 | "type": "section", 187 | }, 188 | Object { 189 | "type": "divider", 190 | }, 191 | Object { 192 | "block_id": "section1", 193 | "text": Object { 194 | "text": "\`\`\`code\`\`\`", 195 | "type": "mrkdwn", 196 | "verbatim": false, 197 | }, 198 | "type": "section", 199 | }, 200 | ], 201 | "channel": "test_channel", 202 | "mrkdwn": true, 203 | "response_type": "in_channel", 204 | "text": "this is alt text", 205 | "token": "test_token", 206 | } 207 | `; 208 | 209 | exports[`slack jsx component with component props 1`] = ` 210 | Object { 211 | "accessory": Object { 212 | "alt_text": "foo", 213 | "image_url": "foo", 214 | "type": "image", 215 | }, 216 | "fields": Array [ 217 | Object { 218 | "text": "**foo**: bar", 219 | "type": "mrkdwn", 220 | "verbatim": false, 221 | }, 222 | Object { 223 | "text": "**foo**: bar", 224 | "type": "mrkdwn", 225 | "verbatim": false, 226 | }, 227 | ], 228 | "text": Object { 229 | "text": "\`\`\`code\`\`\`", 230 | "type": "mrkdwn", 231 | "verbatim": false, 232 | }, 233 | "type": "section", 234 | } 235 | `; 236 | 237 | exports[`slack jsx component with single nested component child 1`] = ` 238 | Object { 239 | "elements": Array [ 240 | Object { 241 | "emoji": false, 242 | "text": "fooooo", 243 | "type": "plain_text", 244 | }, 245 | ], 246 | "type": "context", 247 | } 248 | `; 249 | 250 | exports[`slack jsx component with single string child 1`] = ` 251 | Object { 252 | "emoji": false, 253 | "text": "fooooo", 254 | "type": "plain_text", 255 | } 256 | `; 257 | 258 | exports[`slack jsx component with span array children 1`] = ` 259 | Object { 260 | "text": "link <https://google.com|hi &amp; &lt;google&gt;>\\\\n hi user <@U12345>", 261 | "type": "mrkdwn", 262 | "verbatim": false, 263 | } 264 | `; 265 | 266 | exports[`slack jsx flattens multiple sections in an array 1`] = ` 267 | Object { 268 | "blocks": Array [ 269 | Object { 270 | "text": Object { 271 | "text": "Hey <@42>,<@42> has requested your review for <http://foo.com|code review x>'", 272 | "type": "mrkdwn", 273 | "verbatim": false, 274 | }, 275 | "type": "section", 276 | }, 277 | Object { 278 | "text": Object { 279 | "emoji": false, 280 | "text": "1", 281 | "type": "plain_text", 282 | }, 283 | "type": "section", 284 | }, 285 | Object { 286 | "text": Object { 287 | "emoji": false, 288 | "text": "2", 289 | "type": "plain_text", 290 | }, 291 | "type": "section", 292 | }, 293 | Object { 294 | "text": Object { 295 | "emoji": false, 296 | "text": "3", 297 | "type": "plain_text", 298 | }, 299 | "type": "section", 300 | }, 301 | ], 302 | "mrkdwn": true, 303 | "response_type": "in_channel", 304 | "text": "New code review request", 305 | } 306 | `; 307 | 308 | exports[`slack jsx message with complex fallback text 1`] = ` 309 | Object { 310 | "blocks": Array [], 311 | "mrkdwn": true, 312 | "response_type": "in_channel", 313 | "text": "Hey <@foo>progress: \`▓▓▓▓▓▓▓░░░\`", 314 | } 315 | `; 316 | 317 | exports[`slack jsx message with simple fallback text 1`] = ` 318 | Object { 319 | "blocks": Array [], 320 | "mrkdwn": false, 321 | "response_type": "in_channel", 322 | "text": "simple message text", 323 | } 324 | `; 325 | 326 | exports[`slack jsx only renders as_user when explicitly set 1`] = ` 327 | Object { 328 | "as_user": true, 329 | "blocks": Array [ 330 | Object { 331 | "text": Object { 332 | "emoji": true, 333 | "text": "sent as user", 334 | "type": "plain_text", 335 | }, 336 | "type": "section", 337 | }, 338 | ], 339 | "channel": "test_channel", 340 | "mrkdwn": true, 341 | "response_type": "in_channel", 342 | "text": "as user", 343 | "token": "test_token", 344 | } 345 | `; 346 | 347 | exports[`slack jsx renders ActionsBlock elements as children 1`] = ` 348 | Object { 349 | "elements": Array [ 350 | Object { 351 | "action_id": "foo", 352 | "text": Object { 353 | "emoji": true, 354 | "text": "Click", 355 | "type": "plain_text", 356 | }, 357 | "type": "button", 358 | }, 359 | Object { 360 | "alt_text": "alt", 361 | "image_url": "https://api.slack.com/img/blocks/bkb_template_images/beagle.png", 362 | "type": "image", 363 | }, 364 | ], 365 | "type": "actions", 366 | } 367 | `; 368 | 369 | exports[`slack jsx renders BlockQuote 1`] = ` 370 | Object { 371 | "text": ">this is a test 372 | >this too is a test", 373 | "type": "mrkdwn", 374 | } 375 | `; 376 | 377 | exports[`slack jsx renders ChannelsSelect 1`] = ` 378 | Object { 379 | "action_id": "channels-action-id", 380 | "placeholder": Object { 381 | "text": "", 382 | "type": "plain_text", 383 | }, 384 | "type": "channels_select", 385 | } 386 | `; 387 | 388 | exports[`slack jsx renders ConversationsSelect with filter 1`] = ` 389 | Object { 390 | "action_id": "conversations-action-id", 391 | "filter": Object { 392 | "exclude_bot_users": undefined, 393 | "exclude_external_shared_channels": undefined, 394 | "include": Array [ 395 | "public", 396 | ], 397 | }, 398 | "placeholder": Object { 399 | "text": "", 400 | "type": "plain_text", 401 | }, 402 | "response_url_enabled": true, 403 | "type": "conversations_select", 404 | } 405 | `; 406 | 407 | exports[`slack jsx renders ConversationsSelect with no filter 1`] = ` 408 | Object { 409 | "action_id": "conversations-action-id", 410 | "placeholder": Object { 411 | "text": "", 412 | "type": "plain_text", 413 | }, 414 | "response_url_enabled": true, 415 | "type": "conversations_select", 416 | } 417 | `; 418 | 419 | exports[`slack jsx renders Home view 1`] = ` 420 | Object { 421 | "blocks": Array [ 422 | Object { 423 | "text": Object { 424 | "text": "this is a test 425 | this too is a test", 426 | "type": "mrkdwn", 427 | "verbatim": false, 428 | }, 429 | "type": "section", 430 | }, 431 | ], 432 | "title": Object { 433 | "text": "Title Text", 434 | "type": "plain_text", 435 | }, 436 | "type": "home", 437 | } 438 | `; 439 | 440 | exports[`slack jsx renders a beta time picker 1`] = ` 441 | Object { 442 | "action_id": "actionid", 443 | "initial_time": "22:04", 444 | "placeholder": Object { 445 | "emoji": false, 446 | "text": "placeholder text", 447 | "type": "plain_text", 448 | }, 449 | "type": "timepicker", 450 | } 451 | `; 452 | 453 | exports[`slack jsx renders a complex message 1`] = ` 454 | Object { 455 | "blocks": Array [ 456 | Object { 457 | "text": Object { 458 | "text": "Hey <@foo> 459 | Hot code review alert! :thermometer: 460 | <foo|some title> has *34 discussions*. You're missing out!", 461 | "type": "mrkdwn", 462 | "verbatim": false, 463 | }, 464 | "type": "section", 465 | }, 466 | Object { 467 | "block_id": "action-block-id", 468 | "elements": Array [ 469 | Object { 470 | "action_id": "view", 471 | "style": "primary", 472 | "text": Object { 473 | "emoji": true, 474 | "text": "Get involved!", 475 | "type": "plain_text", 476 | }, 477 | "type": "button", 478 | }, 479 | Object { 480 | "action_id": "snooze", 481 | "text": Object { 482 | "emoji": true, 483 | "text": "Snooze", 484 | "type": "plain_text", 485 | }, 486 | "type": "button", 487 | }, 488 | Object { 489 | "action_id": "mute", 490 | "style": "danger", 491 | "text": Object { 492 | "emoji": true, 493 | "text": "Please stop!", 494 | "type": "plain_text", 495 | }, 496 | "type": "button", 497 | }, 498 | ], 499 | "type": "actions", 500 | }, 501 | Object { 502 | "elements": Array [ 503 | Object { 504 | "emoji": false, 505 | "text": "This review has been open for N days.", 506 | "type": "plain_text", 507 | }, 508 | ], 509 | "type": "context", 510 | }, 511 | ], 512 | "mrkdwn": true, 513 | "response_type": "in_channel", 514 | "text": "Hot code review alert", 515 | } 516 | `; 517 | 518 | exports[`slack jsx renders a multi-select with option_groups 1`] = ` 519 | Object { 520 | "action_id": "action1", 521 | "initial_options": Array [ 522 | Object { 523 | "text": Object { 524 | "emoji": false, 525 | "text": "option 1", 526 | "type": "plain_text", 527 | }, 528 | "value": "value1", 529 | }, 530 | ], 531 | "option_groups": Array [ 532 | Object { 533 | "label": Object { 534 | "emoji": false, 535 | "text": "Empty Group", 536 | "type": "plain_text", 537 | }, 538 | "options": Array [], 539 | }, 540 | Object { 541 | "label": Object { 542 | "emoji": false, 543 | "text": "Group 1", 544 | "type": "plain_text", 545 | }, 546 | "options": Array [ 547 | Object { 548 | "text": Object { 549 | "emoji": false, 550 | "text": "option 1", 551 | "type": "plain_text", 552 | }, 553 | "value": "value1", 554 | }, 555 | Object { 556 | "text": Object { 557 | "emoji": false, 558 | "text": "option 2", 559 | "type": "plain_text", 560 | }, 561 | "value": "value2", 562 | }, 563 | ], 564 | }, 565 | Object { 566 | "label": Object { 567 | "emoji": false, 568 | "text": "Group 2", 569 | "type": "plain_text", 570 | }, 571 | "options": Array [ 572 | Object { 573 | "text": Object { 574 | "emoji": false, 575 | "text": "option 1", 576 | "type": "plain_text", 577 | }, 578 | "value": "value3", 579 | }, 580 | Object { 581 | "text": Object { 582 | "emoji": false, 583 | "text": "option 2", 584 | "type": "plain_text", 585 | }, 586 | "value": "value4", 587 | }, 588 | ], 589 | }, 590 | ], 591 | "placeholder": Object { 592 | "emoji": false, 593 | "text": "placeholder text", 594 | "type": "plain_text", 595 | }, 596 | "type": "multi_static_select", 597 | } 598 | `; 599 | 600 | exports[`slack jsx renders a radiobutton group 1`] = ` 601 | Object { 602 | "action_id": "action1", 603 | "initial_option": Object { 604 | "description": Object { 605 | "emoji": true, 606 | "text": "description", 607 | "type": "plain_text", 608 | }, 609 | "text": Object { 610 | "emoji": false, 611 | "text": "on", 612 | "type": "plain_text", 613 | }, 614 | "value": "value1", 615 | }, 616 | "options": Array [ 617 | Object { 618 | "description": Object { 619 | "emoji": true, 620 | "text": "description", 621 | "type": "plain_text", 622 | }, 623 | "text": Object { 624 | "emoji": false, 625 | "text": "on", 626 | "type": "plain_text", 627 | }, 628 | "value": "value1", 629 | }, 630 | Object { 631 | "text": Object { 632 | "emoji": false, 633 | "text": "off", 634 | "type": "plain_text", 635 | }, 636 | "url": "https://botany.io", 637 | "value": "value2", 638 | }, 639 | ], 640 | "type": "radio_buttons", 641 | } 642 | `; 643 | 644 | exports[`slack jsx renders a simple checkbox 1`] = ` 645 | Object { 646 | "action_id": "action1", 647 | "options": Array [ 648 | Object { 649 | "description": Object { 650 | "emoji": true, 651 | "text": "description", 652 | "type": "plain_text", 653 | }, 654 | "text": Object { 655 | "emoji": false, 656 | "text": "option 1", 657 | "type": "plain_text", 658 | }, 659 | "value": "value1", 660 | }, 661 | Object { 662 | "text": Object { 663 | "emoji": false, 664 | "text": "option 2", 665 | "type": "plain_text", 666 | }, 667 | "url": "https://botany.io", 668 | "value": "value2", 669 | }, 670 | ], 671 | "type": "checkboxes", 672 | } 673 | `; 674 | 675 | exports[`slack jsx renders a simple multi-select 1`] = ` 676 | Object { 677 | "action_id": "action1", 678 | "initial_options": Array [ 679 | Object { 680 | "text": Object { 681 | "emoji": false, 682 | "text": "option 1", 683 | "type": "plain_text", 684 | }, 685 | "value": "value1", 686 | }, 687 | ], 688 | "options": Array [ 689 | Object { 690 | "text": Object { 691 | "emoji": false, 692 | "text": "option 1", 693 | "type": "plain_text", 694 | }, 695 | "value": "value1", 696 | }, 697 | Object { 698 | "text": Object { 699 | "emoji": false, 700 | "text": "option 2", 701 | "type": "plain_text", 702 | }, 703 | "value": "value2", 704 | }, 705 | ], 706 | "placeholder": Object { 707 | "emoji": false, 708 | "text": "placeholder text", 709 | "type": "plain_text", 710 | }, 711 | "type": "multi_static_select", 712 | } 713 | `; 714 | 715 | exports[`slack jsx renders a simple single-select 1`] = ` 716 | Object { 717 | "action_id": "action1", 718 | "initial_option": Object { 719 | "text": Object { 720 | "emoji": false, 721 | "text": "option 1", 722 | "type": "plain_text", 723 | }, 724 | "value": "value1", 725 | }, 726 | "options": Array [ 727 | Object { 728 | "text": Object { 729 | "emoji": false, 730 | "text": "option 1", 731 | "type": "plain_text", 732 | }, 733 | "value": "value1", 734 | }, 735 | Object { 736 | "text": Object { 737 | "emoji": false, 738 | "text": "option 2", 739 | "type": "plain_text", 740 | }, 741 | "value": "value2", 742 | }, 743 | ], 744 | "placeholder": Object { 745 | "emoji": false, 746 | "text": "placeholder text", 747 | "type": "plain_text", 748 | }, 749 | "type": "static_select", 750 | } 751 | `; 752 | 753 | exports[`slack jsx renders a single-select with option_groups 1`] = ` 754 | Object { 755 | "action_id": "action1", 756 | "initial_option": Object { 757 | "text": Object { 758 | "emoji": false, 759 | "text": "option 1", 760 | "type": "plain_text", 761 | }, 762 | "value": "value1", 763 | }, 764 | "option_groups": Array [ 765 | Object { 766 | "label": Object { 767 | "emoji": false, 768 | "text": "Group 1", 769 | "type": "plain_text", 770 | }, 771 | "options": Array [ 772 | Object { 773 | "text": Object { 774 | "emoji": false, 775 | "text": "option 1", 776 | "type": "plain_text", 777 | }, 778 | "value": "value1", 779 | }, 780 | Object { 781 | "text": Object { 782 | "emoji": false, 783 | "text": "option 2", 784 | "type": "plain_text", 785 | }, 786 | "value": "value2", 787 | }, 788 | ], 789 | }, 790 | Object { 791 | "label": Object { 792 | "emoji": false, 793 | "text": "Group 2", 794 | "type": "plain_text", 795 | }, 796 | "options": Array [ 797 | Object { 798 | "text": Object { 799 | "emoji": false, 800 | "text": "option 1", 801 | "type": "plain_text", 802 | }, 803 | "value": "value3", 804 | }, 805 | Object { 806 | "text": Object { 807 | "emoji": false, 808 | "text": "option 2", 809 | "type": "plain_text", 810 | }, 811 | "value": "value4", 812 | }, 813 | ], 814 | }, 815 | ], 816 | "placeholder": Object { 817 | "emoji": false, 818 | "text": "placeholder text", 819 | "type": "plain_text", 820 | }, 821 | "type": "static_select", 822 | } 823 | `; 824 | 825 | exports[`slack jsx renders an external multi-select 1`] = ` 826 | Object { 827 | "action_id": "action1", 828 | "initial_options": Array [ 829 | Object { 830 | "text": Object { 831 | "emoji": false, 832 | "text": "option 1", 833 | "type": "plain_text", 834 | }, 835 | "value": "value1", 836 | }, 837 | ], 838 | "min_query_length": 0, 839 | "placeholder": Object { 840 | "emoji": false, 841 | "text": "placeholder text", 842 | "type": "plain_text", 843 | }, 844 | "type": "multi_external_select", 845 | } 846 | `; 847 | 848 | exports[`slack jsx renders an overflow menu 1`] = ` 849 | Object { 850 | "accessory": Object { 851 | "action_id": "action1", 852 | "options": Array [ 853 | Object { 854 | "text": Object { 855 | "emoji": false, 856 | "text": "option 1", 857 | "type": "plain_text", 858 | }, 859 | "value": "value1", 860 | }, 861 | Object { 862 | "text": Object { 863 | "emoji": false, 864 | "text": "option 2", 865 | "type": "plain_text", 866 | }, 867 | "value": "value2", 868 | }, 869 | ], 870 | "type": "overflow", 871 | }, 872 | "text": Object { 873 | "text": "This is a section block with an overflow menu.", 874 | "type": "mrkdwn", 875 | "verbatim": false, 876 | }, 877 | "type": "section", 878 | } 879 | `; 880 | 881 | exports[`slack jsx renders contextblock children 1`] = ` 882 | Object { 883 | "elements": Array [ 884 | Object { 885 | "emoji": true, 886 | "text": "Hello, world", 887 | "type": "plain_text", 888 | }, 889 | Object { 890 | "alt_text": "alt", 891 | "image_url": "https://api.slack.com/img/blocks/bkb_template_images/beagle.png", 892 | "type": "image", 893 | }, 894 | ], 895 | "type": "context", 896 | } 897 | `; 898 | 899 | exports[`slack jsx renders contextblock with component children 1`] = ` 900 | Object { 901 | "elements": Array [ 902 | Object { 903 | "emoji": true, 904 | "text": "Hello, world", 905 | "type": "plain_text", 906 | }, 907 | Object { 908 | "alt_text": "declined", 909 | "image_url": "https://user-images.githubusercontent.com/97470/75739424-a7ac1800-5cb9-11ea-969a-e1ac9f12a41a.png", 910 | "type": "image", 911 | }, 912 | "okay!", 913 | ], 914 | "type": "context", 915 | } 916 | `; 917 | 918 | exports[`slack jsx renders inline text elements with promises returning text elements 1`] = ` 919 | Object { 920 | "elements": Array [ 921 | Object { 922 | "text": "Hello, world! <@foo><@bar>, :thumbsup:", 923 | "type": "mrkdwn", 924 | "verbatim": false, 925 | }, 926 | ], 927 | "type": "context", 928 | } 929 | `; 930 | 931 | exports[`slack jsx renders with a class prop 1`] = ` 932 | Object { 933 | "blocks": Array [ 934 | Object { 935 | "text": Object { 936 | "text": "can you see me", 937 | "type": "mrkdwn", 938 | "verbatim": false, 939 | }, 940 | "type": "section", 941 | }, 942 | ], 943 | "mrkdwn": true, 944 | "response_type": "in_channel", 945 | "text": "Code review activity", 946 | } 947 | `; 948 | 949 | exports[`slack jsx simple input block test 1`] = ` 950 | Object { 951 | "element": Object { 952 | "action_id": "action1", 953 | "initial_option": Object { 954 | "text": Object { 955 | "emoji": false, 956 | "text": "option 1", 957 | "type": "plain_text", 958 | }, 959 | "value": "value1", 960 | }, 961 | "options": Array [ 962 | Object { 963 | "text": Object { 964 | "emoji": false, 965 | "text": "option 1", 966 | "type": "plain_text", 967 | }, 968 | "value": "value1", 969 | }, 970 | Object { 971 | "text": Object { 972 | "emoji": false, 973 | "text": "option 2", 974 | "type": "plain_text", 975 | }, 976 | "value": "value2", 977 | }, 978 | ], 979 | "placeholder": Object { 980 | "emoji": false, 981 | "text": "placeholder text", 982 | "type": "plain_text", 983 | }, 984 | "type": "static_select", 985 | }, 986 | "label": Object { 987 | "emoji": true, 988 | "text": "test", 989 | "type": "plain_text", 990 | }, 991 | "optional": true, 992 | "type": "input", 993 | } 994 | `; 995 | -------------------------------------------------------------------------------- /src/__tests__/renderer.test.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx slack.h */ 2 | /** @jsxFrag slack.Fragment */ 3 | import { View } from '@slack/types'; 4 | import { 5 | slack, 6 | render, 7 | ActionsBlock, 8 | DividerBlock, 9 | ContextBlock, 10 | SectionBlock, 11 | ImageElement, 12 | ButtonElement, 13 | PlainText, 14 | MarkdownText, 15 | Link, 16 | Mention, 17 | ProgressBar, 18 | Message, 19 | AltText, 20 | LineBreak, 21 | BlockQuote, 22 | SingleSelectElement, 23 | MultiSelectElement, 24 | Home, 25 | ActionsBlockProps, 26 | CheckboxElement, 27 | FC, 28 | } from '..'; 29 | import { 30 | RadioButtonsElement, 31 | ConversationsSelect, 32 | ChannelSelect, 33 | InputBlock, 34 | TimePickerElement, 35 | MultiExternalSelectElement, 36 | OverflowMenuElement, 37 | } from '../components'; 38 | 39 | const fakePromise = async () => Promise.resolve(); 40 | 41 | describe('slack jsx', () => { 42 | it('message with complex fallback text', async () => { 43 | const message = ( 44 | <Message 45 | altText={ 46 | <AltText> 47 | Hey <Mention userId="foo" /> 48 | progress:{' '} 49 | <ProgressBar columnWidth={10} total={300} value={200} color="red" /> 50 | </AltText> 51 | } 52 | ></Message> 53 | ); 54 | expect(await render(message)).toMatchSnapshot(); 55 | }); 56 | 57 | it('message with simple fallback text', async () => { 58 | const message = ( 59 | <Message 60 | altText={<AltText mrkdwn={false}>simple message text</AltText>} 61 | ></Message> 62 | ); 63 | expect(await render(message)).toMatchSnapshot(); 64 | }); 65 | 66 | it('component with single string child', async () => { 67 | const message = <PlainText>fooooo</PlainText>; 68 | expect(await render(message)).toMatchSnapshot(); 69 | }); 70 | 71 | it('can render simple fragments', async () => { 72 | const CustomBlock = async () => Promise.resolve(<DividerBlock />); 73 | 74 | const message = ( 75 | <Message altText={<AltText>My weekly summary</AltText>}> 76 | <> 77 | <DividerBlock /> 78 | <CustomBlock /> 79 | <DividerBlock /> 80 | </> 81 | </Message> 82 | ); 83 | 84 | expect(await render(message)).toMatchSnapshot(); 85 | }); 86 | 87 | it('can ignore falsy children', async () => { 88 | const CustomBlock = async () => 89 | Promise.resolve( 90 | <> 91 | <DividerBlock /> 92 | {false} 93 | </> 94 | ); 95 | 96 | const message = ( 97 | <Message altText={<AltText>My weekly summary</AltText>}> 98 | <DividerBlock /> 99 | {null} 100 | <> 101 | <CustomBlock /> 102 | {false} 103 | <DividerBlock /> 104 | </> 105 | {undefined} 106 | </Message> 107 | ); 108 | 109 | expect(await render(message)).toMatchSnapshot(); 110 | }); 111 | 112 | it('can render nested fragments in maps', async () => { 113 | const userMotivations = [ 114 | { 115 | motivation: { 116 | name: 'm1', 117 | motivationHabits: [ 118 | { 119 | habit: { 120 | name: 'h1', 121 | userHabits: [ 122 | { 123 | habit: { 124 | name: 'uh1', 125 | }, 126 | }, 127 | { 128 | habit: { name: 'uh2' }, 129 | }, 130 | ], 131 | }, 132 | }, 133 | { 134 | habit: { 135 | name: 'h2', 136 | userHabits: [ 137 | { 138 | habit: { name: 'uh3' }, 139 | }, 140 | { 141 | habit: { name: 'uh4' }, 142 | }, 143 | ], 144 | }, 145 | }, 146 | ], 147 | }, 148 | }, 149 | ]; 150 | 151 | const msg = ( 152 | <Message altText={<AltText>My weekly summary</AltText>}> 153 | <DividerBlock /> 154 | <SectionBlock> 155 | <MarkdownText>My growth this week</MarkdownText> 156 | </SectionBlock> 157 | <DividerBlock /> 158 | {userMotivations.map(({ motivation: { name, motivationHabits } }) => 159 | motivationHabits.map(({ habit: { userHabits } }) => 160 | userHabits.map(({ habit: { name: habitName } }) => ( 161 | <> 162 | <SectionBlock> 163 | <MarkdownText> 164 | *{name}: {habitName}* 165 | </MarkdownText> 166 | </SectionBlock> 167 | <ContextBlock> 168 | <ImageElement 169 | altText="improved" 170 | imageUrl="https://user-images.githubusercontent.com/97470/75739421-a7138180-5cb9-11ea-9547-e64acf86eb59.png" 171 | /> 172 | <ImageElement 173 | altText="declined" 174 | imageUrl="https://user-images.githubusercontent.com/97470/75739424-a7ac1800-5cb9-11ea-969a-e1ac9f12a41a.png" 175 | /> 176 | <MarkdownText> 177 | :evergreen_tree: :small_red_triangle_down: stats go here 178 | </MarkdownText> 179 | </ContextBlock> 180 | </> 181 | )) 182 | ) 183 | )} 184 | </Message> 185 | ); 186 | 187 | expect(await render(msg)).toMatchSnapshot(); 188 | }); 189 | 190 | it('component with single nested component child', async () => { 191 | const message = ( 192 | <ContextBlock> 193 | <PlainText>fooooo</PlainText> 194 | </ContextBlock> 195 | ); 196 | expect(await render(message)).toMatchSnapshot(); 197 | }); 198 | 199 | it('component with span array children', async () => { 200 | const linkText = 'hi & <google>'; 201 | const message = ( 202 | <MarkdownText> 203 | link <Link href="https://google.com">{linkText}</Link>\n hi user{' '} 204 | <Mention userId="U12345" /> 205 | </MarkdownText> 206 | ); 207 | expect(await render(message)).toMatchSnapshot(); 208 | }); 209 | 210 | it('component with array of nested component children', async () => { 211 | const message = ( 212 | <Message 213 | token="test_token" 214 | channel="test_channel" 215 | altText={<AltText>this is alt text</AltText>} 216 | > 217 | <SectionBlock> 218 | <PlainText emoji>section text :sadkeanu:</PlainText> 219 | </SectionBlock> 220 | <DividerBlock /> 221 | <SectionBlock blockId="section1"> 222 | <MarkdownText>```code```</MarkdownText> 223 | </SectionBlock> 224 | </Message> 225 | ); 226 | expect(await render(message)).toMatchSnapshot(); 227 | }); 228 | 229 | it('component with component props', async () => { 230 | const message = ( 231 | <SectionBlock 232 | fields={[ 233 | <MarkdownText>**foo**: bar</MarkdownText>, 234 | <MarkdownText>**foo**: bar</MarkdownText>, 235 | ]} 236 | accessory={<ImageElement imageUrl="foo" altText="foo" />} 237 | > 238 | <MarkdownText>```code```</MarkdownText> 239 | </SectionBlock> 240 | ); 241 | expect(await render(message)).toMatchSnapshot(); 242 | }); 243 | 244 | it('renders ActionsBlock elements as children', async () => { 245 | expect( 246 | await render( 247 | <ActionsBlock> 248 | <ButtonElement actionId="foo">Click</ButtonElement> 249 | <ImageElement 250 | imageUrl="https://api.slack.com/img/blocks/bkb_template_images/beagle.png" 251 | altText="alt" 252 | /> 253 | </ActionsBlock> 254 | ) 255 | ).toMatchSnapshot(); 256 | }); 257 | 258 | it('renders Markdown with a ProgressBar', async () => { 259 | expect( 260 | await render( 261 | <MarkdownText> 262 | progress: <ProgressBar columnWidth={10} total={300} value={200} /> 263 | </MarkdownText> 264 | ) 265 | ).toMatchObject({ 266 | type: 'mrkdwn', 267 | text: 'progress: ▓▓▓▓▓▓▓░░░', 268 | verbatim: false, 269 | }); 270 | }); 271 | 272 | it('renders a Red ProgressBar', async () => { 273 | expect( 274 | await render( 275 | <MarkdownText> 276 | progress:{' '} 277 | <ProgressBar columnWidth={10} total={300} value={200} color="red" /> 278 | </MarkdownText> 279 | ) 280 | ).toMatchObject({ 281 | type: 'mrkdwn', 282 | text: 'progress: `▓▓▓▓▓▓▓░░░`', 283 | verbatim: false, 284 | }); 285 | }); 286 | 287 | it('renders a complex message', async () => { 288 | const message = ( 289 | <Message altText={<AltText>Hot code review alert</AltText>}> 290 | <SectionBlock> 291 | <MarkdownText> 292 | Hey <Mention userId="foo" /> 293 | <LineBreak /> 294 | Hot code review alert! :thermometer: 295 | <LineBreak /> 296 | <Link href="foo">some title</Link> has *{34} discussions*. You're 297 | missing out! 298 | </MarkdownText> 299 | </SectionBlock> 300 | <ActionsBlock blockId="action-block-id"> 301 | <ButtonElement actionId="view" style="primary"> 302 | Get involved! 303 | </ButtonElement> 304 | <ButtonElement actionId="snooze">Snooze</ButtonElement> 305 | <ButtonElement actionId="mute" style="danger"> 306 | Please stop! 307 | </ButtonElement> 308 | </ActionsBlock> 309 | <ContextBlock> 310 | <PlainText>This review has been open for N days.</PlainText> 311 | </ContextBlock> 312 | </Message> 313 | ); 314 | expect(await render(message)).toMatchSnapshot(); 315 | }); 316 | 317 | it('flattens multiple sections in an array', async () => { 318 | const message = ( 319 | <Message altText={<AltText>New code review request</AltText>}> 320 | <SectionBlock> 321 | {true ? ( 322 | <MarkdownText> 323 | Hey <Mention userId="42" />, 324 | <Mention userId="42" /> has requested your review for{' '} 325 | <Link href="http://foo.com">code review x</Link>' 326 | </MarkdownText> 327 | ) : ( 328 | <MarkdownText> 329 | <Link href="http://google.com"> 330 | Looks like you're assigned to a PullRequest 331 | </Link> 332 | </MarkdownText> 333 | )} 334 | </SectionBlock> 335 | {[1, 2, 3].map(num => ( 336 | <SectionBlock> 337 | <PlainText>{num.toString()}</PlainText> 338 | </SectionBlock> 339 | ))} 340 | </Message> 341 | ); 342 | expect(await render(message)).toMatchSnapshot(); 343 | }); 344 | 345 | const DeltaIndicator = async ({ delta }: { delta: number }) => { 346 | await fakePromise(); 347 | 348 | return delta > 0 ? ( 349 | <ImageElement 350 | altText="improved" 351 | imageUrl="https://user-images.githubusercontent.com/97470/75739421-a7138180-5cb9-11ea-9547-e64acf86eb59.png" 352 | /> 353 | ) : delta === 0 ? ( 354 | 'okay!' 355 | ) : ( 356 | <ImageElement 357 | altText="declined" 358 | imageUrl="https://user-images.githubusercontent.com/97470/75739424-a7ac1800-5cb9-11ea-969a-e1ac9f12a41a.png" 359 | /> 360 | ); 361 | }; 362 | 363 | it('renders contextblock with component children', async () => { 364 | const message = ( 365 | <ContextBlock> 366 | <PlainText emoji>Hello, world</PlainText> 367 | <DeltaIndicator delta={-3} /> 368 | <DeltaIndicator delta={0} /> 369 | </ContextBlock> 370 | ); 371 | 372 | expect(await render(message)).toMatchSnapshot(); 373 | }); 374 | 375 | it('renders contextblock children', async () => { 376 | const message = ( 377 | <ContextBlock> 378 | <PlainText emoji>Hello, world</PlainText> 379 | <ImageElement 380 | imageUrl="https://api.slack.com/img/blocks/bkb_template_images/beagle.png" 381 | altText="alt" 382 | /> 383 | </ContextBlock> 384 | ); 385 | 386 | expect(await render(message)).toMatchSnapshot(); 387 | }); 388 | 389 | it('renders inline text elements with promises returning text elements', async () => { 390 | const renderText = () => { 391 | const strings = ['foo', 'bar'].map(s => <Mention userId={s} />); 392 | return <>world! {strings}</>; 393 | }; 394 | 395 | const Emoji: FC<{ value: number }, string> = ({ value }) => ':thumbsup:'; 396 | 397 | const message = ( 398 | <ContextBlock> 399 | <MarkdownText> 400 | Hello, {renderText()}, <Emoji value={1} /> 401 | </MarkdownText> 402 | </ContextBlock> 403 | ); 404 | 405 | expect(await render(message)).toMatchSnapshot(); 406 | }); 407 | 408 | it('only renders as_user when explicitly set', async () => { 409 | const message = ( 410 | <Message 411 | token="test_token" 412 | channel="test_channel" 413 | altText={<AltText>as user</AltText>} 414 | asUser 415 | > 416 | <SectionBlock> 417 | <PlainText emoji>sent as user</PlainText> 418 | </SectionBlock> 419 | </Message> 420 | ); 421 | expect(await render(message)).toMatchSnapshot(); 422 | }); 423 | 424 | it('can create custom components based on block kit', async () => { 425 | const NudgeActionsBlock: FC< 426 | ActionsBlockProps & { nudgeState: { id: number } }, 427 | ReturnType<typeof ActionsBlock> 428 | > = ({ nudgeState, children, ...rest }) => ( 429 | <ActionsBlock {...rest} blockId={nudgeState.id.toString()}> 430 | {children} 431 | </ActionsBlock> 432 | ); 433 | }); 434 | 435 | it('renders BlockQuote', async () => { 436 | const message = ( 437 | <BlockQuote>this is a test{'\n'}this too is a test</BlockQuote> 438 | ); 439 | expect(await render(message)).toMatchSnapshot(); 440 | }); 441 | 442 | it('renders an external multi-select', async () => { 443 | const message = ( 444 | <MultiExternalSelectElement 445 | actionId="action1" 446 | placeholder={<PlainText>placeholder text</PlainText>} 447 | initialOptions={[ 448 | { 449 | text: <PlainText>option 1</PlainText>, 450 | value: 'value1', 451 | }, 452 | ]} 453 | /> 454 | ); 455 | 456 | expect(await render(message)).toMatchSnapshot(); 457 | }); 458 | 459 | it('renders a simple multi-select', async () => { 460 | const message = ( 461 | <MultiSelectElement 462 | actionId="action1" 463 | placeholder={<PlainText>placeholder text</PlainText>} 464 | initialOptions={[ 465 | { 466 | text: <PlainText>option 1</PlainText>, 467 | value: 'value1', 468 | }, 469 | ]} 470 | options={[ 471 | { 472 | text: <PlainText>option 1</PlainText>, 473 | value: 'value1', 474 | }, 475 | { 476 | text: <PlainText>option 2</PlainText>, 477 | value: 'value2', 478 | }, 479 | ]} 480 | /> 481 | ); 482 | 483 | console.log(JSON.stringify(await render(message), null, 2)); 484 | 485 | expect(await render(message)).toMatchSnapshot(); 486 | }); 487 | 488 | it('renders a multi-select with option_groups', async () => { 489 | const message = ( 490 | <MultiSelectElement 491 | actionId="action1" 492 | placeholder={<PlainText>placeholder text</PlainText>} 493 | initialOptions={[ 494 | { 495 | text: <PlainText>option 1</PlainText>, 496 | value: 'value1', 497 | }, 498 | ]} 499 | optionGroups={[ 500 | { 501 | label: <PlainText>Empty Group</PlainText>, 502 | options: [], 503 | }, 504 | { 505 | label: <PlainText>Group 1</PlainText>, 506 | options: [ 507 | { 508 | text: <PlainText>option 1</PlainText>, 509 | value: 'value1', 510 | }, 511 | { 512 | text: <PlainText>option 2</PlainText>, 513 | value: 'value2', 514 | }, 515 | ], 516 | }, 517 | { 518 | label: <PlainText>Group 2</PlainText>, 519 | options: [ 520 | { 521 | text: <PlainText>option 1</PlainText>, 522 | value: 'value3', 523 | }, 524 | { 525 | text: <PlainText>option 2</PlainText>, 526 | value: 'value4', 527 | }, 528 | ], 529 | }, 530 | ]} 531 | /> 532 | ); 533 | 534 | const view = await render(message); 535 | 536 | expect(view.option_groups[0].options).toMatchObject([]); 537 | 538 | console.log(JSON.stringify(view, null, 2)); 539 | 540 | expect(view).toMatchSnapshot(); 541 | }); 542 | 543 | it('renders a simple single-select', async () => { 544 | const message = ( 545 | <SingleSelectElement 546 | actionId="action1" 547 | initialOption={{ 548 | text: <PlainText>option 1</PlainText>, 549 | value: 'value1', 550 | }} 551 | placeholder={<PlainText>placeholder text</PlainText>} 552 | options={[ 553 | { 554 | text: <PlainText>option 1</PlainText>, 555 | value: 'value1', 556 | }, 557 | { 558 | text: <PlainText>option 2</PlainText>, 559 | value: 'value2', 560 | }, 561 | ]} 562 | /> 563 | ); 564 | expect(await render(message)).toMatchSnapshot(); 565 | }); 566 | 567 | it('renders a single-select with option_groups', async () => { 568 | const message = ( 569 | <SingleSelectElement 570 | actionId="action1" 571 | initialOption={{ 572 | text: <PlainText>option 1</PlainText>, 573 | value: 'value1', 574 | }} 575 | placeholder={<PlainText>placeholder text</PlainText>} 576 | optionGroups={[ 577 | { 578 | label: <PlainText>Group 1</PlainText>, 579 | options: [ 580 | { 581 | text: <PlainText>option 1</PlainText>, 582 | value: 'value1', 583 | }, 584 | { 585 | text: <PlainText>option 2</PlainText>, 586 | value: 'value2', 587 | }, 588 | ], 589 | }, 590 | { 591 | label: <PlainText>Group 2</PlainText>, 592 | options: [ 593 | { 594 | text: <PlainText>option 1</PlainText>, 595 | value: 'value3', 596 | }, 597 | { 598 | text: <PlainText>option 2</PlainText>, 599 | value: 'value4', 600 | }, 601 | ], 602 | }, 603 | ]} 604 | /> 605 | ); 606 | expect(await render(message)).toMatchSnapshot(); 607 | }); 608 | 609 | it('renders a simple checkbox', async () => { 610 | const message = ( 611 | <CheckboxElement 612 | actionId="action1" 613 | options={[ 614 | { 615 | text: <PlainText>option 1</PlainText>, 616 | description: 'description', 617 | value: 'value1', 618 | }, 619 | { 620 | text: <PlainText>option 2</PlainText>, 621 | value: 'value2', 622 | url: 'https://botany.io', 623 | }, 624 | ]} 625 | /> 626 | ); 627 | expect(await render(message)).toMatchSnapshot(); 628 | }); 629 | 630 | it('renders a radiobutton group', async () => { 631 | const message = ( 632 | <RadioButtonsElement 633 | actionId="action1" 634 | options={[ 635 | { 636 | text: <PlainText>on</PlainText>, 637 | description: 'description', 638 | value: 'value1', 639 | }, 640 | { 641 | text: <PlainText>off</PlainText>, 642 | value: 'value2', 643 | url: 'https://botany.io', 644 | }, 645 | ]} 646 | initialOption={{ 647 | text: <PlainText>on</PlainText>, 648 | description: 'description', 649 | value: 'value1', 650 | }} 651 | /> 652 | ); 653 | expect(await render(message)).toMatchSnapshot(); 654 | }); 655 | 656 | it('renders Home view', async () => { 657 | const message = ( 658 | <Home title="Title Text"> 659 | <SectionBlock> 660 | <MarkdownText>this is a test{'\n'}this too is a test</MarkdownText> 661 | </SectionBlock> 662 | </Home> 663 | ); 664 | expect(await render(message)).toMatchSnapshot(); 665 | }); 666 | 667 | it('renders ChannelsSelect', async () => { 668 | const message = <ChannelSelect actionId="channels-action-id" />; 669 | expect(await render(message)).toMatchSnapshot(); 670 | }); 671 | 672 | it('renders ConversationsSelect with no filter', async () => { 673 | const message = ( 674 | <ConversationsSelect 675 | responseUrlEnabled={true} 676 | actionId="conversations-action-id" 677 | /> 678 | ); 679 | expect(await render(message)).toMatchSnapshot(); 680 | }); 681 | 682 | it('renders ConversationsSelect with filter', async () => { 683 | const message = ( 684 | <ConversationsSelect 685 | responseUrlEnabled={true} 686 | actionId="conversations-action-id" 687 | filter={{ include: ['public'] }} 688 | /> 689 | ); 690 | expect(await render(message)).toMatchSnapshot(); 691 | }); 692 | 693 | it('simple input block test', async () => { 694 | const message = ( 695 | <InputBlock label="test" optional={true}> 696 | <SingleSelectElement 697 | actionId="action1" 698 | initialOption={{ 699 | text: <PlainText>option 1</PlainText>, 700 | value: 'value1', 701 | }} 702 | placeholder={<PlainText>placeholder text</PlainText>} 703 | options={[ 704 | { 705 | text: <PlainText>option 1</PlainText>, 706 | value: 'value1', 707 | }, 708 | { 709 | text: <PlainText>option 2</PlainText>, 710 | value: 'value2', 711 | }, 712 | ]} 713 | />{' '} 714 | </InputBlock> 715 | ); 716 | expect(await render(message)).toMatchSnapshot(); 717 | }); 718 | 719 | it('renders a beta time picker', async () => { 720 | const message = ( 721 | <TimePickerElement 722 | initialTime="22:04" 723 | actionId="actionid" 724 | placeholder={<PlainText>placeholder text</PlainText>} 725 | /> 726 | ); 727 | expect(await render(message)).toMatchSnapshot(); 728 | }); 729 | 730 | it('renders with a class prop', async () => { 731 | const expectedNameProp = 'testName'; 732 | class TestClass { 733 | private name: string; 734 | constructor(name: string) { 735 | this.name = name; 736 | } 737 | 738 | get theName(): string { 739 | return this.name; 740 | } 741 | } 742 | 743 | interface ComponentProps { 744 | testClass: TestClass; 745 | children?: never; 746 | } 747 | 748 | const Component: FC<ComponentProps, ReturnType<typeof Message>> = ({ 749 | testClass, 750 | }) => { 751 | const name = testClass.theName; 752 | expect(name).toEqual(expectedNameProp); 753 | return ( 754 | <SectionBlock> 755 | <MarkdownText>can you see me</MarkdownText> 756 | </SectionBlock> 757 | ); 758 | }; 759 | 760 | const message = ( 761 | <Message altText={<AltText>Code review activity</AltText>}> 762 | <Component testClass={new TestClass(expectedNameProp)} /> 763 | </Message> 764 | ); 765 | expect(await render(message)).toMatchSnapshot(); 766 | }); 767 | 768 | it('renders an overflow menu', async () => { 769 | const message = ( 770 | <SectionBlock 771 | accessory={ 772 | <OverflowMenuElement 773 | actionId="action1" 774 | options={[ 775 | { 776 | text: <PlainText>option 1</PlainText>, 777 | value: 'value1', 778 | }, 779 | { 780 | text: <PlainText>option 2</PlainText>, 781 | value: 'value2', 782 | }, 783 | ]} 784 | /> 785 | } 786 | > 787 | <MarkdownText> 788 | This is a section block with an overflow menu. 789 | </MarkdownText> 790 | </SectionBlock> 791 | ); 792 | expect(await render(message)).toMatchSnapshot(); 793 | }); 794 | }); 795 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderMarkdown } from '../renderMarkdown'; 2 | describe('renderMarkdown', () => { 3 | it('renders markdown to slack markup', () => { 4 | const markdown = ` 5 | Thanks! 6 | 7 | Your review of [Update README.md](https://github.com/botany-sanjuans/kayaking/pull/20) was helpful because... 8 | 9 | **hero :bug: :thumbsup:** 10 | **🌩 got it done quick** 11 | 12 | Your tag collection: 13 | **foo**: (*2x*) 14 | `; 15 | 16 | const slackMarkup = renderMarkdown(markdown); 17 | 18 | expect(slackMarkup).toMatchInlineSnapshot(` 19 | "Thanks! 20 | 21 | Your review of <https://github.com/botany-sanjuans/kayaking/pull/20|Update README.md> was helpful because... 22 | 23 | ​*hero :bug: :thumbsup:*​ 24 | ​*🌩 got it done quick*​ 25 | 26 | Your tag collection: 27 | ​*foo*​: (​_2x_​) 28 | " 29 | `); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/ActionsBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockProps } from './Block'; 2 | import { ActionsBlock as ActionsBlockSpec } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | 5 | export type ActionsBlockProps = BlockProps & ContainerProps<any>; 6 | 7 | export const ActionsBlock: Block<ActionsBlockProps, ActionsBlockSpec> = ({ 8 | blockId, 9 | children, 10 | }) => ({ 11 | type: 'actions', 12 | block_id: blockId, 13 | elements: [].concat(children), 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/AnyText.ts: -------------------------------------------------------------------------------- 1 | import { PlainText } from './PlainText'; 2 | import { MarkdownText } from './MarkdownText'; 3 | 4 | export type AnyText = typeof PlainText | typeof MarkdownText; 5 | -------------------------------------------------------------------------------- /src/components/Block.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | import { KnownBlock } from '@slack/types'; 3 | import { HeaderBlockSpec } from './HeaderBlock'; 4 | 5 | export interface BlockProps { 6 | blockId?: string; 7 | } 8 | 9 | // slack types don't yet include HeaderBlock 10 | export type AllowedBlocks = KnownBlock | HeaderBlockSpec; 11 | 12 | export type Block<P extends BlockProps, B extends AllowedBlocks> = FC<P, B>; 13 | -------------------------------------------------------------------------------- /src/components/BlockElement.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | import { 3 | ImageElement as ImageElementSpec, 4 | Button as ButtonSpec, 5 | Button, 6 | Overflow, 7 | Datepicker, 8 | Select, 9 | MultiSelect, 10 | Action, 11 | } from '@slack/types'; 12 | 13 | export type ActionType = 'button'; 14 | 15 | export type ActionSpec = ButtonSpec; 16 | 17 | export type ElementType = 'image' | 'user' | ActionType; 18 | 19 | export type ElementSpec = 20 | | ImageElementSpec 21 | // | ActionSpec 22 | | Button 23 | | Overflow 24 | | Datepicker 25 | | Select 26 | | MultiSelect 27 | | Action; 28 | 29 | export type BlockElement<P extends {}, E extends ElementSpec> = FC<P, E>; 30 | -------------------------------------------------------------------------------- /src/components/BlockQuote.ts: -------------------------------------------------------------------------------- 1 | import { ContainerProps } from './ContainerProps'; 2 | import { MarkdownTextProps, MarkdownText } from './MarkdownText'; 3 | import { joinTextChildren } from './Text'; 4 | 5 | export type BlockQuoteProps = MarkdownTextProps & ContainerProps<string>; 6 | 7 | const applyMarkdownQuotation = (message: string): string => { 8 | return '>' + message.replace(/\n/g, '\n>'); 9 | }; 10 | 11 | export const BlockQuote: typeof MarkdownText = (props: BlockQuoteProps) => { 12 | return { 13 | type: 'mrkdwn', 14 | text: applyMarkdownQuotation(joinTextChildren(props.children)), 15 | verbatim: props.verbatim, 16 | }; 17 | }; 18 | 19 | export default BlockQuote; 20 | -------------------------------------------------------------------------------- /src/components/ButtonElement.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | import { Button as ButtonSpec } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | import { joinTextChildren } from './Text'; 5 | 6 | export interface ButtonElementProps extends ContainerProps<string> { 7 | actionId: string; 8 | url?: string; 9 | value?: string; 10 | style?: 'primary' | 'danger'; 11 | } 12 | 13 | export const ButtonElement: FC<ButtonElementProps, ButtonSpec> = ({ 14 | children, 15 | actionId: action_id, 16 | style, 17 | url, 18 | value, 19 | }) => ({ 20 | type: 'button', 21 | text: { 22 | // plain_text allows only plain_text 23 | type: 'plain_text', 24 | emoji: true, 25 | text: joinTextChildren(children), 26 | }, 27 | action_id, 28 | url, 29 | value, 30 | style, 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/ChannelsSelect.ts: -------------------------------------------------------------------------------- 1 | import { joinTextChildren } from './Text'; 2 | import { ChannelsSelect as ChannelSelectSpec } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | import { FC } from '..'; 5 | 6 | export interface ChannelSelectProps extends ContainerProps<string> { 7 | initialChannel?: string; 8 | actionId?: string; 9 | } 10 | 11 | export const ChannelSelect: FC<ChannelSelectProps, ChannelSelectSpec> = ({ 12 | children, 13 | initialChannel, 14 | actionId, 15 | }) => { 16 | const select: ChannelSelectSpec = { 17 | type: 'channels_select', 18 | initial_channel: initialChannel, 19 | action_id: actionId, 20 | }; 21 | 22 | if (children) { 23 | select.placeholder = { 24 | type: 'plain_text', 25 | text: joinTextChildren(children), 26 | }; 27 | } 28 | 29 | return select; 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/Checkbox.ts: -------------------------------------------------------------------------------- 1 | import { Checkboxes } from '@slack/types'; 2 | import { FC } from '..'; 3 | import { buildInputOptions, InputOption } from './shared/inputOption'; 4 | 5 | export interface CheckboxElementProps { 6 | initialOptions?: InputOption[]; 7 | actionId: string; 8 | confirm?: boolean; 9 | options: InputOption[]; 10 | } 11 | 12 | export const CheckboxElement: FC<CheckboxElementProps, Checkboxes> = ({ 13 | initialOptions, 14 | actionId, 15 | options, 16 | }) => ({ 17 | type: 'checkboxes', 18 | action_id: actionId, 19 | options: buildInputOptions(options), 20 | ...(initialOptions && { 21 | initial_options: buildInputOptions(initialOptions), 22 | }), 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/Childless.ts: -------------------------------------------------------------------------------- 1 | export interface Childless { 2 | children?: never; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/ContainerProps.ts: -------------------------------------------------------------------------------- 1 | export interface ContainerProps<C> { 2 | children?: C | C[]; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/ContextBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockProps } from './Block'; 2 | import { ContextBlock as ContextBlockSpec } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | import { AnyText } from './AnyText'; 5 | import { ImageElement } from './ImageElement'; 6 | 7 | export type ContextBlockProps = BlockProps & 8 | ContainerProps<ReturnType<AnyText> | ReturnType<typeof ImageElement>>; 9 | 10 | export const ContextBlock: Block<ContextBlockProps, ContextBlockSpec> = ({ 11 | children, 12 | blockId, 13 | }) => ({ 14 | type: 'context', 15 | elements: [].concat(children), 16 | block_id: blockId, 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/ConversationsSelect.ts: -------------------------------------------------------------------------------- 1 | import { joinTextChildren } from './Text'; 2 | import { ConversationsSelect as ConversationsSelectSpec } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | import { FC } from '..'; 5 | 6 | export interface ConversationSelectProps extends ContainerProps<string> { 7 | initialConversation?: string; 8 | actionId: string; 9 | filter?: { 10 | include?: ('im' | 'mpim' | 'private' | 'public')[]; 11 | excludeExternalSharedChannels?: boolean; 12 | excludeBotUsers?: boolean; 13 | }; 14 | exclude_external_shared_channels?: boolean; 15 | exclude_bot_users?: boolean; 16 | responseUrlEnabled?: boolean; 17 | } 18 | 19 | export const ConversationsSelect: FC< 20 | ConversationSelectProps, 21 | ConversationsSelectSpec 22 | > = ({ 23 | children, 24 | initialConversation, 25 | actionId, 26 | filter, 27 | responseUrlEnabled = false, 28 | }) => { 29 | const select: ConversationsSelectSpec = { 30 | type: 'conversations_select', 31 | initial_conversation: initialConversation, 32 | action_id: actionId, 33 | response_url_enabled: responseUrlEnabled, 34 | ...(filter && { 35 | filter: { 36 | include: filter.include, 37 | exclude_bot_users: filter.excludeBotUsers, 38 | exclude_external_shared_channels: filter.excludeExternalSharedChannels, 39 | }, 40 | }), 41 | }; 42 | 43 | if (children) { 44 | select.placeholder = { 45 | type: 'plain_text', 46 | text: joinTextChildren(children), 47 | }; 48 | } 49 | 50 | return select; 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/DividerBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockProps } from './Block'; 2 | import { DividerBlock as DividerBlockSpec } from '@slack/types'; 3 | import { Childless } from './Childless'; 4 | 5 | export type DividerBlockProps = BlockProps & Childless; 6 | 7 | export const DividerBlock: Block<DividerBlockProps, DividerBlockSpec> = ({ 8 | blockId, 9 | }) => ({ 10 | type: 'divider', 11 | block_id: blockId, 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/ExternalSelect.ts: -------------------------------------------------------------------------------- 1 | import { MultiExternalSelect } from '@slack/types'; 2 | import { FC, SelectElementProps } from '..'; 3 | import { buildInputOptions, InputOption } from './shared/inputOption'; 4 | 5 | export interface MultiExternalSelectElementProps extends SelectElementProps { 6 | initialOptions?: InputOption[]; 7 | } 8 | 9 | export const MultiExternalSelectElement: FC< 10 | MultiExternalSelectElementProps, 11 | MultiExternalSelect 12 | > = ({ placeholder, actionId, initialOptions }) => ({ 13 | type: 'multi_external_select', 14 | placeholder, 15 | action_id: actionId, 16 | min_query_length: 0, 17 | initial_options: buildInputOptions(initialOptions), 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/HeaderBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockProps } from './Block'; 2 | import { ContainerProps } from './ContainerProps'; 3 | import { PlainText } from './PlainText'; 4 | 5 | export interface HeaderBlockProps 6 | extends BlockProps, 7 | ContainerProps<typeof PlainText> {} 8 | 9 | export interface HeaderBlockSpec { 10 | type: 'header'; 11 | text: ReturnType<typeof PlainText>; 12 | } 13 | 14 | export const HeaderBlock: Block<HeaderBlockProps, HeaderBlockSpec> = ({ 15 | children, 16 | blockId, 17 | }) => ({ 18 | type: 'header', 19 | block_id: blockId, 20 | text: [].concat(children)[0], 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Home.ts: -------------------------------------------------------------------------------- 1 | // https://api.slack.com/surfaces/tabs/using 2 | 3 | import { FC, ModalProps, ModalSpec } from '..'; 4 | 5 | export const Home: FC<ModalProps, ModalSpec> = ({ 6 | children, 7 | callbackId, 8 | title, 9 | submitButtonText, 10 | closeButtonText, 11 | privateMetadata, 12 | clearOnClose, 13 | notifyOnClose, 14 | }) => { 15 | const modal: ModalSpec = { 16 | type: 'home', 17 | callback_id: callbackId, 18 | blocks: Array.isArray(children) ? children : [].concat(children), 19 | title: { type: 'plain_text', text: title }, 20 | private_metadata: privateMetadata, 21 | clear_on_close: clearOnClose, 22 | notify_on_close: notifyOnClose, 23 | }; 24 | 25 | if (submitButtonText) { 26 | modal.submit = { type: 'plain_text', text: submitButtonText }; 27 | } 28 | if (closeButtonText) { 29 | modal.close = { type: 'plain_text', text: closeButtonText }; 30 | } 31 | 32 | return modal; 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/ImageBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockProps } from './Block'; 2 | import { ImageBlock as ImageBlockSpec } from '@slack/types'; 3 | import { Childless } from './Childless'; 4 | 5 | export interface ImageBlockProps extends BlockProps, Childless { 6 | altText: string; 7 | imageUrl: string; 8 | title?: string; 9 | } 10 | 11 | export const ImageBlock: Block<ImageBlockProps, ImageBlockSpec> = ({ 12 | blockId, 13 | imageUrl, 14 | altText, 15 | title, 16 | }) => ({ 17 | type: 'image', 18 | block_id: blockId, 19 | image_url: imageUrl, 20 | alt_text: altText, 21 | title: { 22 | type: 'plain_text', 23 | text: title, 24 | emoji: true, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/ImageElement.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | import { ImageElement as ImageElementSpec } from '@slack/types'; 3 | 4 | export interface ImageElementProps { 5 | imageUrl: string; 6 | altText: string; 7 | } 8 | 9 | export const ImageElement: FC<ImageElementProps, ImageElementSpec> = ({ 10 | imageUrl: image_url, 11 | altText: alt_text, 12 | }) => ({ 13 | type: 'image', 14 | image_url, 15 | alt_text, 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/InputBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockProps } from './Block'; 2 | import { InputBlock as InputBlockSpec } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | 5 | export interface InputBlockProps extends BlockProps, ContainerProps<any> { 6 | label: string; 7 | hint?: string; 8 | optional?: boolean; 9 | } 10 | 11 | export const InputBlock: Block<InputBlockProps, InputBlockSpec> = ({ 12 | label, 13 | hint, 14 | blockId, 15 | children, 16 | optional, 17 | }) => { 18 | const spec: InputBlockSpec = { 19 | type: 'input', 20 | label: { type: 'plain_text', text: label, emoji: true }, 21 | block_id: blockId, 22 | element: children[0], 23 | optional, 24 | }; 25 | 26 | if (hint) { 27 | spec.hint = { type: 'plain_text', text: hint, emoji: true }; 28 | } 29 | 30 | return spec; 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/LineBreak.ts: -------------------------------------------------------------------------------- 1 | import { Span } from './Span'; 2 | import { Childless } from './Childless'; 3 | 4 | export const LineBreak: Span<Childless> = () => { 5 | return '\n'; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/Link.ts: -------------------------------------------------------------------------------- 1 | import { Span } from './Span'; 2 | import { ContainerProps } from './ContainerProps'; 3 | import { joinTextChildren } from './Text'; 4 | 5 | // https://api.slack.com/reference/surfaces/formatting#escaping 6 | const escape = (s: string) => 7 | s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;'); 8 | 9 | export interface LinkProps extends ContainerProps<string> { 10 | href: string; 11 | } 12 | 13 | export const Link: Span<LinkProps> = (props: LinkProps) => { 14 | return `<${props.href}|${escape(joinTextChildren(props.children))}>`; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/MarkdownText.ts: -------------------------------------------------------------------------------- 1 | import { TextProps, Text, joinTextChildren } from './Text'; 2 | import { MrkdwnElement } from '@slack/types'; 3 | 4 | export interface MarkdownTextProps extends TextProps { 5 | verbatim?: boolean; 6 | } 7 | 8 | export const MarkdownText: Text<MarkdownTextProps, MrkdwnElement> = ({ 9 | children, 10 | verbatim = false, 11 | }) => ({ 12 | type: 'mrkdwn', 13 | text: joinTextChildren(children), 14 | verbatim, 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Mention.ts: -------------------------------------------------------------------------------- 1 | import { Span } from './Span'; 2 | 3 | export interface MentionProps { 4 | userId: string; 5 | } 6 | 7 | export const Mention: Span<MentionProps> = ({ userId }) => `<@${userId}>`; 8 | -------------------------------------------------------------------------------- /src/components/Message.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | import { ContainerProps } from './ContainerProps'; 3 | import { Block } from './Block'; 4 | import { AltText } from './MessageText'; 5 | import { MessageTextSpec } from './Text'; 6 | import { KnownBlock } from '@slack/types'; 7 | 8 | export type MessageType = 'ephemeral' | 'in_channel'; 9 | 10 | export interface MessageProps 11 | extends ContainerProps<ReturnType<Block<any, KnownBlock>>> { 12 | altText: ReturnType<typeof AltText>; 13 | responseType?: MessageType; 14 | channel?: string; 15 | token?: string; 16 | asUser?: boolean; 17 | } 18 | 19 | export interface MessageSpec extends MessageTextSpec { 20 | response_type: MessageType; 21 | channel?: string; 22 | as_user?: boolean; 23 | token?: string; 24 | blocks?: ReturnType<Block<any, KnownBlock>>[]; 25 | } 26 | 27 | export const Message: FC<MessageProps, MessageSpec> = ({ 28 | children, 29 | responseType = 'in_channel', 30 | channel, 31 | token, 32 | altText, 33 | asUser, 34 | }) => { 35 | const message = { 36 | response_type: responseType, 37 | blocks: Array.isArray(children) ? children : [].concat(children), 38 | as_user: asUser, 39 | channel, 40 | token, 41 | ...altText, 42 | }; 43 | 44 | if (asUser) { 45 | message.as_user = asUser; 46 | } 47 | return message; 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/MessageText.ts: -------------------------------------------------------------------------------- 1 | // https://api.slack.com/reference/messaging/payload 2 | import { 3 | TextProps, 4 | MessageText as Text, 5 | joinTextChildren, 6 | MessageTextSpec, 7 | } from './Text'; 8 | 9 | export interface MessageTextProps extends TextProps { 10 | mrkdwn?: boolean; 11 | } 12 | 13 | export const AltText: Text<MessageTextProps, MessageTextSpec> = ({ 14 | children, 15 | mrkdwn = true, 16 | }) => ({ 17 | mrkdwn, 18 | text: joinTextChildren(children), 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/Modal.ts: -------------------------------------------------------------------------------- 1 | // https://api.slack.com/reference/surfaces/views 2 | 3 | import { FC } from '..'; 4 | import { ContainerProps } from './ContainerProps'; 5 | import { Block } from './Block'; 6 | import { KnownBlock, View } from '@slack/types'; 7 | 8 | export interface ModalProps 9 | extends ContainerProps<ReturnType<Block<any, KnownBlock>>> { 10 | title: string; 11 | callbackId?: string; 12 | submitButtonText?: string; 13 | closeButtonText?: string; 14 | privateMetadata?: string; 15 | clearOnClose?: boolean; 16 | notifyOnClose?: boolean; 17 | } 18 | 19 | // TODO: maybe just rename Modal -> View. slack docs make this a bit confusing 20 | // in the meantime exporting this spec for consistency 21 | export type ModalSpec = View; 22 | 23 | export const Modal: FC<ModalProps, ModalSpec> = ({ 24 | children, 25 | callbackId, 26 | title, 27 | submitButtonText, 28 | closeButtonText, 29 | privateMetadata, 30 | clearOnClose, 31 | notifyOnClose, 32 | }) => { 33 | const modal: ModalSpec = { 34 | type: 'modal', 35 | callback_id: callbackId, 36 | blocks: Array.isArray(children) ? children : [].concat(children), 37 | title: { type: 'plain_text', text: title }, 38 | private_metadata: privateMetadata, 39 | clear_on_close: clearOnClose, 40 | notify_on_close: notifyOnClose, 41 | }; 42 | 43 | if (submitButtonText) { 44 | modal.submit = { type: 'plain_text', text: submitButtonText }; 45 | } 46 | if (closeButtonText) { 47 | modal.close = { type: 'plain_text', text: closeButtonText }; 48 | } 49 | 50 | return modal; 51 | }; 52 | -------------------------------------------------------------------------------- /src/components/MultiSelectElement.ts: -------------------------------------------------------------------------------- 1 | import { MultiStaticSelect } from '@slack/types'; 2 | import { FC, SelectElementProps } from '..'; 3 | import { buildInputOptions, InputOption } from './shared/inputOption'; 4 | 5 | export interface MultiSelectElementProps extends SelectElementProps { 6 | initialOptions?: InputOption[]; 7 | } 8 | 9 | export const MultiSelectElement: FC< 10 | MultiSelectElementProps, 11 | MultiStaticSelect 12 | > = ({ placeholder, actionId, options, optionGroups, initialOptions }) => ({ 13 | type: 'multi_static_select', 14 | placeholder, 15 | action_id: actionId, 16 | ...(options && { options: buildInputOptions(options) }), 17 | ...(optionGroups && { 18 | option_groups: optionGroups.map(group => ({ 19 | label: group.label, 20 | options: buildInputOptions(group.options), 21 | })), 22 | }), 23 | ...(initialOptions && { 24 | initial_options: buildInputOptions(initialOptions), 25 | }), 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/OverflowMenuElement.ts: -------------------------------------------------------------------------------- 1 | import { StaticSelect, Overflow } from '@slack/types'; 2 | import { FC } from '..'; 3 | import { buildInputOptions, InputOption } from './shared/inputOption'; 4 | 5 | export interface OverflowMenuElementProps { 6 | actionId: string; 7 | options: InputOption[]; 8 | } 9 | 10 | export const OverflowMenuElement: FC<OverflowMenuElementProps, Overflow> = ({ 11 | actionId, 12 | options, 13 | }) => ({ 14 | type: 'overflow', 15 | action_id: actionId, 16 | ...(options && { options: buildInputOptions(options) }), 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/PlainText.ts: -------------------------------------------------------------------------------- 1 | import { TextProps, Text, joinTextChildren } from './Text'; 2 | import { PlainTextElement } from '@slack/types'; 3 | 4 | export interface PlainTextProps extends TextProps { 5 | emoji?: boolean; 6 | } 7 | 8 | export const PlainText: Text<PlainTextProps, PlainTextElement> = ({ 9 | children, 10 | emoji = false, 11 | }) => ({ 12 | type: 'plain_text', 13 | text: joinTextChildren(children), 14 | emoji, 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/PlainTextInputElement.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | import { PlainTextInput as PlainTextInputSpec } from '@slack/types'; 3 | 4 | export interface PlainTextInputProps { 5 | placeholderText?: string; 6 | initialValue?: string; 7 | multiline?: boolean; 8 | minLength?: number; 9 | maxLength?: number; 10 | actionId?: string; 11 | } 12 | 13 | export const PlainTextInputElement: FC< 14 | PlainTextInputProps, 15 | PlainTextInputSpec 16 | > = ({ 17 | placeholderText, 18 | initialValue, 19 | multiline, 20 | minLength: min_length, 21 | maxLength: max_length, 22 | actionId: action_id, 23 | }) => { 24 | const plainTextInput: PlainTextInputSpec = { 25 | type: 'plain_text_input', 26 | multiline, 27 | min_length, 28 | max_length, 29 | action_id, 30 | }; 31 | 32 | if (placeholderText) { 33 | plainTextInput.placeholder = { type: 'plain_text', text: placeholderText }; 34 | } 35 | 36 | if (initialValue) { 37 | plainTextInput.initial_value = initialValue; 38 | } 39 | 40 | return plainTextInput; 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/ProgressBar.ts: -------------------------------------------------------------------------------- 1 | import { Span } from './Span'; 2 | 3 | const COMPLETED_CHAR = '▓'; 4 | const INCOMPLETE_CHAR = '░'; 5 | 6 | interface ProgressBarProps { 7 | value: number; 8 | total: number; 9 | color?: 'red' | 'black'; 10 | columnWidth?: number; 11 | } 12 | 13 | export const ProgressBar: Span<ProgressBarProps> = ({ 14 | value, 15 | total, 16 | color = 'black', 17 | columnWidth = 5, 18 | }) => { 19 | const completedCount = Math.ceil((value / total) * columnWidth); 20 | const incompleteCount = columnWidth - completedCount; 21 | const segments = 22 | COMPLETED_CHAR.repeat(completedCount) + 23 | INCOMPLETE_CHAR.repeat(incompleteCount); 24 | 25 | return color === 'red' ? `\`${segments}\`` : segments; 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/RadioButtons.ts: -------------------------------------------------------------------------------- 1 | import { RadioButtons } from '@slack/types'; 2 | import { FC } from '..'; 3 | import { buildInputOptions, InputOption } from './shared/inputOption'; 4 | 5 | export interface RadioButtonsElementProps { 6 | initialOption?: InputOption; 7 | actionId: string; 8 | options: InputOption[]; 9 | } 10 | 11 | export const RadioButtonsElement: FC< 12 | RadioButtonsElementProps, 13 | RadioButtons 14 | > = ({ initialOption, actionId, options }) => ({ 15 | type: 'radio_buttons', 16 | action_id: actionId, 17 | options: buildInputOptions(options), 18 | initial_option: buildInputOptions([initialOption])[0], 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/SectionBlock.ts: -------------------------------------------------------------------------------- 1 | import { Block, BlockProps } from './Block'; 2 | import { SectionBlock as SectionBlockSpec } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | import { AnyText } from './AnyText'; 5 | import { ElementSpec } from './BlockElement'; 6 | 7 | export interface SectionBlockProps extends BlockProps, ContainerProps<AnyText> { 8 | fields?: ReturnType<AnyText>[]; 9 | accessory?: ElementSpec; 10 | } 11 | 12 | export const SectionBlock: Block<SectionBlockProps, SectionBlockSpec> = ({ 13 | children, 14 | accessory, 15 | fields, 16 | blockId, 17 | }) => ({ 18 | type: 'section', 19 | block_id: blockId, 20 | text: [].concat(children)[0], 21 | accessory, 22 | fields, 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/SingleSelectElement.ts: -------------------------------------------------------------------------------- 1 | import { StaticSelect, PlainTextElement } from '@slack/types'; 2 | import { FC } from '..'; 3 | import { buildInputOptions, InputOption } from './shared/inputOption'; 4 | 5 | export interface SelectElementProps { 6 | placeholder: PlainTextElement; 7 | actionId: string; 8 | options?: InputOption[]; 9 | optionGroups?: { label: PlainTextElement; options: InputOption[] }[]; 10 | } 11 | 12 | export interface SingleSelectElementProps extends SelectElementProps { 13 | initialOption?: InputOption; 14 | } 15 | 16 | export const SingleSelectElement: FC< 17 | SingleSelectElementProps, 18 | StaticSelect 19 | > = ({ placeholder, actionId, options, optionGroups, initialOption }) => ({ 20 | type: 'static_select', 21 | placeholder, 22 | action_id: actionId, 23 | ...(options && { options: buildInputOptions(options) }), 24 | ...(optionGroups && { 25 | option_groups: optionGroups.map(group => ({ 26 | label: group.label, 27 | options: buildInputOptions(group.options), 28 | })), 29 | }), 30 | initial_option: buildInputOptions([initialOption])[0], 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/Span.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | 3 | export type Span<P> = FC<P, string>; 4 | -------------------------------------------------------------------------------- /src/components/Text.ts: -------------------------------------------------------------------------------- 1 | import { FC } from '..'; 2 | import { MrkdwnElement, PlainTextElement } from '@slack/types'; 3 | import { ContainerProps } from './ContainerProps'; 4 | 5 | // https://api.slack.com/reference/messaging/composition-objects#text 6 | 7 | // custom 'spec' since this isn't defined in '@slack/types' 8 | export interface MessageTextSpec { 9 | text: string; 10 | mrkdwn: boolean; 11 | } 12 | 13 | export type TextType = 'plain_text' | 'mrkdwn'; 14 | 15 | export type TextProps = ContainerProps<string>; 16 | 17 | export type TextElementSpec = MrkdwnElement | PlainTextElement; 18 | 19 | export type Text<P extends TextProps, E extends TextElementSpec> = FC<P, E>; 20 | 21 | export type MessageText<P extends TextProps, E extends MessageTextSpec> = FC< 22 | P, 23 | E 24 | >; 25 | 26 | export const joinTextChildren = (children: string | string[]): string => { 27 | // console.log('TEXT CHILDREN', children); 28 | return [].concat(children).join(''); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/TimePicker.ts: -------------------------------------------------------------------------------- 1 | import { PlainTextElement } from '@slack/types'; 2 | import { FC } from '..'; 3 | 4 | export interface TimePickerSpec { 5 | initial_time?: string; 6 | placeholder: PlainTextElement; 7 | action_id: string; 8 | type: 'timepicker'; 9 | } 10 | 11 | export interface TimePickerElementProps { 12 | initialTime?: string; 13 | placeholder: PlainTextElement; 14 | actionId: string; 15 | } 16 | 17 | export const TimePickerElement: FC<TimePickerElementProps, TimePickerSpec> = ({ 18 | initialTime, 19 | actionId, 20 | placeholder, 21 | }) => ({ 22 | type: 'timepicker', 23 | action_id: actionId, 24 | initial_time: initialTime, 25 | placeholder, 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Message'; 2 | 3 | export * from './Modal'; 4 | export * from './Home'; 5 | export * from './ActionsBlock'; 6 | export * from './ContextBlock'; 7 | export * from './DividerBlock'; 8 | export * from './InputBlock'; 9 | export * from './SectionBlock'; 10 | export * from './ImageBlock'; 11 | export * from './HeaderBlock'; 12 | 13 | export * from './ButtonElement'; 14 | export * from './ImageElement'; 15 | export * from './PlainTextInputElement'; 16 | export * from './ChannelsSelect'; 17 | export * from './ConversationsSelect'; 18 | export * from './SingleSelectElement'; 19 | export * from './MultiSelectElement'; 20 | export * from './ExternalSelect'; 21 | export * from './Checkbox'; 22 | export * from './RadioButtons'; 23 | export * from './OverflowMenuElement'; 24 | export { InputOption } from './shared/inputOption'; 25 | 26 | export * from './PlainText'; 27 | export * from './MarkdownText'; 28 | export * from './MessageText'; 29 | 30 | export * from './Link'; 31 | export * from './Mention'; 32 | export * from './ProgressBar'; 33 | export * from './LineBreak'; 34 | export * from './BlockQuote'; 35 | export * from './TimePicker'; 36 | -------------------------------------------------------------------------------- /src/components/shared/inputOption.ts: -------------------------------------------------------------------------------- 1 | import { Option } from '@slack/types'; 2 | import { AnyText } from '../AnyText'; 3 | 4 | export interface InputOption { 5 | text: ReturnType<AnyText>; 6 | value?: string; 7 | description?: string; 8 | url?: string; 9 | } 10 | 11 | export const buildInputOptions = (inputOptions: InputOption[]): Option[] => 12 | inputOptions 13 | ? inputOptions.map(inputOption => { 14 | if (!inputOption) { 15 | return; 16 | } 17 | const option: Option = { 18 | text: inputOption.text, 19 | }; 20 | if (inputOption.url) { 21 | option.url = inputOption.url; 22 | } 23 | if (inputOption.value) { 24 | option.value = inputOption.value; 25 | } 26 | if (inputOption.description) { 27 | option.description = { 28 | text: inputOption.description, 29 | type: 'plain_text', 30 | emoji: true, 31 | }; 32 | } 33 | return option; 34 | }) 35 | : []; 36 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import flattenDeep from 'lodash.flattendeep'; 2 | 3 | export type SlackSpec = {} | string; 4 | 5 | const pruneFields = <R>(o: {}): Partial<R> => 6 | o 7 | ? Object.keys(o).reduce( 8 | (obj, k) => (o[k] !== undefined ? { ...obj, [k]: o[k] } : obj), 9 | {} 10 | ) 11 | : undefined; 12 | 13 | type Props<P> = { children?: unknown } & P; 14 | 15 | export type FC<P extends {}, R extends any> = (props: Props<P>) => R; 16 | 17 | const resolveDeep = async (thing: any) => { 18 | if (!thing) { 19 | return thing; 20 | } else if (Array.isArray(thing)) { 21 | return await Promise.all(thing.map(item => resolveDeep(item))); 22 | } else if (thing.__proto__ === Promise.prototype) { 23 | return await Promise.resolve(thing); 24 | } else if (typeof thing === 'object') { 25 | if (Object.getPrototypeOf(thing).constructor.toString().match(/class/)) { 26 | return await Promise.resolve(thing); 27 | } 28 | const resolvedPairs = await Promise.all( 29 | Object.keys(thing).map(async key => [key, await resolveDeep(thing[key])]) 30 | ); 31 | return Object.fromEntries(resolvedPairs); 32 | } else if (typeof thing === 'function') { 33 | return await Promise.resolve(thing); 34 | } else { 35 | return thing; 36 | } 37 | }; 38 | 39 | // for now, this is a hack to help with typescript checking. 40 | // if you wrap a top-level JSX element with `await render(<>)`, 41 | // it's a better experience than just warpping with `await (<>)`. 42 | // in the future, most of the promise awaiting should happen in 43 | // render. h should just return the tree of JSX elements, and 44 | // render should walk the tree and process/await/execute hooks, 45 | // etc. 46 | export const render = async ( 47 | rootElement: Promise<slack.JSX.Element> 48 | ): Promise<any> => await rootElement; 49 | 50 | export namespace slack { 51 | export const h = async ( 52 | node: FC<{}, any>, 53 | props: Props<{}>, 54 | ...children: any[] 55 | ): Promise<JSX.Element> => { 56 | if (typeof node !== 'function') { 57 | throw new Error('node not an FC'); 58 | } 59 | 60 | const resolvedProps = await resolveDeep(props || {}); 61 | 62 | const spec = await node({ 63 | ...resolvedProps, 64 | children: flattenDeep( 65 | await Promise.all( 66 | children.map(async child => { 67 | if (Array.isArray(child)) { 68 | return await Promise.all(flattenDeep(child)); 69 | } else if ( 70 | typeof child === 'function' || 71 | typeof child === 'object' 72 | ) { 73 | return await Promise.resolve(child); 74 | } 75 | 76 | return child; 77 | }) 78 | ) 79 | ).filter(child => !!child), 80 | }); 81 | 82 | return typeof spec === 'string' || Array.isArray(spec) 83 | ? spec 84 | : pruneFields(spec); 85 | }; 86 | 87 | export const Fragment = ({ children }): JSX.Element => { 88 | return children as JSX.Element; 89 | }; 90 | 91 | export namespace JSX { 92 | export type Element = any; 93 | export interface ElementAttributesProperty { 94 | props: {}; 95 | } 96 | export interface ElementChildrenAttribute { 97 | children: {}; 98 | } 99 | } 100 | } 101 | 102 | export * from './components'; 103 | export { renderMarkdown } from './renderMarkdown'; 104 | -------------------------------------------------------------------------------- /src/renderMarkdown.ts: -------------------------------------------------------------------------------- 1 | import slackify from 'slackify-markdown'; 2 | 3 | export const renderMarkdown = (markdown: string): string => slackify(markdown); 4 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "sourceMap": true, 6 | "moduleResolution": "node", 7 | "emitDeclarationOnly": false, 8 | "declaration": true, 9 | // "outFile": "dist/index.d.ts", 10 | "outDir": "dist", 11 | "strict": false, 12 | "allowSyntheticDefaultImports": true, 13 | "esModuleInterop": true, 14 | "jsx": "react" 15 | }, 16 | "exclude": ["src/__tests__"], 17 | "include": ["src", "@types"] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Target latest version of ECMAScript. 4 | "target": "esnext", 5 | "module": "commonjs", 6 | "sourceMap": true, 7 | // Search under node_modules for non-relative imports. 8 | "moduleResolution": "node", 9 | // Process & infer types from .js files. 10 | "allowJs": true, 11 | // Generates corresponding '.d.ts' file. 12 | "declaration": true, 13 | // Don't emit; allow Babel to transform files. 14 | "noEmit": true, 15 | // Enable strictest settings like strictNullChecks & noImplicitAny. 16 | "strict": false, 17 | // Disallow features that require cross-file information for emit. 18 | // "isolatedModules": true, 19 | // Import non-ES modules as default imports. 20 | "esModuleInterop": true, 21 | "jsx": "preserve", 22 | "jsxFactory": "slack.h" 23 | }, 24 | "include": ["src", "@types"] 25 | } 26 | --------------------------------------------------------------------------------