├── .gitignore ├── .npmignore ├── .eslintignore ├── .babelrc ├── .eslintrc.yml ├── examples ├── block-kit-message.js ├── block-kit-modal.js ├── example-blocks.js └── block-kit-message-output.json ├── src ├── utils.js ├── index.js ├── view.js ├── object.js ├── block.js └── element.js ├── package.json ├── README.md └── test ├── block-test.js ├── view-test.js ├── object-test.js └── element-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .nyc_output 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git 2 | .nyc_output 3 | node_modules 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | docs 3 | node_modules 4 | nyc_output 5 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "eslatest-node6" 4 | ], 5 | "plugins": [ 6 | "transform-object-rest-spread" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | root: true 2 | extends: actano-base 3 | plugins: 4 | - no-only-tests 5 | 6 | rules: 7 | no-only-tests/no-only-tests: 'error' 8 | no-unused-expressions: 'off' 9 | env: 10 | mocha: true 11 | -------------------------------------------------------------------------------- /examples/block-kit-message.js: -------------------------------------------------------------------------------- 1 | import createExampleBlocks from './example-blocks' 2 | 3 | const blocks = createExampleBlocks() 4 | // eslint-disable-next-line no-console 5 | console.log(JSON.stringify({ 6 | blocks, 7 | })) 8 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import isString from 'lodash.isstring' 2 | import omitBy from 'lodash.omitby' 3 | import isUndefined from 'lodash.isundefined' 4 | 5 | export const isPresentString = (value, maxLength = 255) => 6 | value && isString(value) && (maxLength === 0 || value.length <= maxLength) 7 | 8 | export const typedWithoutUndefined = (type, props) => omitBy({ 9 | type, 10 | ...props, 11 | }, isUndefined) 12 | 13 | export const withoutUndefined = props => omitBy({ 14 | ...props, 15 | }, isUndefined) 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import block from './block' 2 | import element from './element' 3 | import object, { 4 | TEXT_FORMAT_MRKDWN, 5 | TEXT_FORMAT_PLAIN, 6 | CONVERSATION_TYPE_IM, 7 | CONVERSATION_TYPE_MPIM, 8 | CONVERSATION_TYPE_PUBLIC, 9 | CONVERSATION_TYPE_PRIVATE, 10 | } from './object' 11 | import view from './view' 12 | 13 | export { 14 | object, 15 | element, 16 | block, 17 | view, 18 | TEXT_FORMAT_PLAIN, 19 | TEXT_FORMAT_MRKDWN, 20 | } 21 | 22 | export default { 23 | object, 24 | element, 25 | block, 26 | view, 27 | TEXT_FORMAT_MRKDWN, 28 | TEXT_FORMAT_PLAIN, 29 | CONVERSATION_TYPE_IM, 30 | CONVERSATION_TYPE_MPIM, 31 | CONVERSATION_TYPE_PUBLIC, 32 | CONVERSATION_TYPE_PRIVATE, 33 | } 34 | -------------------------------------------------------------------------------- /examples/block-kit-modal.js: -------------------------------------------------------------------------------- 1 | import createExampleBlocks from './example-blocks' 2 | import { view, block, element, object } from '../src' 3 | 4 | const { input } = block 5 | const { plainTextInput, checkboxes, radioButtons } = element 6 | const { option } = object 7 | 8 | const inputBlocks = [ 9 | input('Your daily basics', checkboxes('daily-checklist', [ 10 | option('Have a breakfast', 'breakfast'), 11 | option('Have a lunch', 'lunch'), 12 | option('Have a dinner', 'dinner'), 13 | option('Sleep', 'sleep'), 14 | option('repeat', 'repeat'), 15 | ]), 16 | { 17 | initialOptions: [ 18 | option('Have a breakfast', 'breakfast'), 19 | option('Have a lunch', 'lunch'), 20 | ] 21 | } 22 | ), 23 | input('Night or Day person?', radioButtons('night-day', [ 24 | option('Night', 'night', { descriptionText: 'Definitely prefer to stay awake late at night' }), 25 | option('Day', 'day', { descriptionText: 'Waking up before dawn, falling asleep with dusk' }) 26 | ])) 27 | ] 28 | 29 | const modalPayload = view.modal( 30 | 'Example modal', 31 | [...inputBlocks, ...createExampleBlocks()], 32 | { 33 | closeText: 'Not now', 34 | submitText: 'Got it', 35 | privateMetadata: { 36 | someVar: 'value', 37 | otherVar: true, 38 | }, 39 | callbackId: 'my-callback-id', 40 | clearOnClose: true, 41 | notifyOnClose: true, 42 | externalId: 'unique-external-id', 43 | }, 44 | ) 45 | // eslint-disable-next-line no-console 46 | console.log(JSON.stringify(modalPayload)) 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack-block-kit", 3 | "version": "0.9.15", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "/dist" 8 | ], 9 | "scripts": { 10 | "clean": "rm -rf dist", 11 | "build": "npm run clean && ./node_modules/.bin/babel -d dist src", 12 | "test": "./node_modules/.bin/babel-node ./node_modules/.bin/_mocha test/*-test.js", 13 | "coverage": "nyc npm run test", 14 | "prepublishOnly": "npm run coverage && npm run build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/workstreams-ai/slack-block-kit.git" 19 | }, 20 | "keywords": [ 21 | "slack", 22 | "block-kit", 23 | "slack-api" 24 | ], 25 | "author": "Workstreams.ai", 26 | "license": "ISC", 27 | "bugs": { 28 | "url": "https://github.com/workstreams-ai/slack-block-kit/issues" 29 | }, 30 | "homepage": "https://github.com/workstreams-ai/slack-block-kit#readme", 31 | "dependencies": { 32 | "lodash.isboolean": "^3.0.3", 33 | "lodash.isnumber": "^3.0.3", 34 | "lodash.isobject": "^3.0.2", 35 | "lodash.isstring": "^4.0.1", 36 | "lodash.isundefined": "^3.0.1", 37 | "lodash.omit": "^4.5.0", 38 | "lodash.omitby": "^4.6.0" 39 | }, 40 | "devDependencies": { 41 | "babel-cli": "^6.22.2", 42 | "babel-core": "^6.22.1", 43 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 44 | "babel-preset-eslatest-node6": "^1.0.1", 45 | "chai": "^4.2.0", 46 | "eslint": "^5.14.1", 47 | "eslint-config-actano-base": "^4.0.0", 48 | "eslint-plugin-import": "^2.16.0", 49 | "eslint-plugin-no-only-tests": "^2.1.0", 50 | "mocha": "^6.2.0", 51 | "nyc": "^13.3.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/example-blocks.js: -------------------------------------------------------------------------------- 1 | import { 2 | object, element, block, TEXT_FORMAT_MRKDWN, 3 | } from '../src' 4 | 5 | const { 6 | text, confirm, option, optionGroup, optionGroups, 7 | } = object 8 | const { 9 | button, overflow, staticSelect, externalSelect, 10 | usersSelect, conversationsSelect, channelsSelect, 11 | datePicker, multiStaticSelect, multiConversationsSelect, multiUsersSelect, multiChannelsSelect, 12 | } = element 13 | const { 14 | section, actions, divider, context, image, 15 | } = block 16 | 17 | const createExampleBlocks = () => [ 18 | section( 19 | text( 20 | '**\nLet\'s *rock* and *roll* towards better future!', 21 | TEXT_FORMAT_MRKDWN, 22 | ), { 23 | fields: [ 24 | text(':heavy_check_mark: *`get stuff done`* :tada:', TEXT_FORMAT_MRKDWN), 25 | text(':arrow_double_up: *Priority* over *deadline* :timer_clock:', TEXT_FORMAT_MRKDWN), 26 | ], 27 | accessory: datePicker('mission-start', { 28 | placeholderText: 'Select a date', 29 | initialDate: '2019-03-23', 30 | }), 31 | }, 32 | ), 33 | actions( 34 | [ 35 | usersSelect('jedi-knight', 'Choose your Jedi'), 36 | conversationsSelect('commanding-team', 'Commanding team'), 37 | channelsSelect('space-ship', 'Choose your squad'), 38 | staticSelect('weapon', 'Choose your weapon', [ 39 | option('Light sabre', 'light-sabre'), 40 | option('PAC cannon', 'pac-cannon'), 41 | option('Dark matter', 'dark-matter'), 42 | option('Telepathy', 'telepathy'), 43 | ], { 44 | initialOption: option('Light sabre', 'light-sabre'), 45 | confirm: confirm( 46 | 'Confirm your weapon', 47 | TEXT_FORMAT_MRKDWN, 48 | 'You *will not* be able to change your weapon, choose wisely :thinking_face:', 49 | 'Choose', 50 | 'Let me think again', 51 | ), 52 | }), 53 | externalSelect('special-weapon', 'Special weapon', { minQueryLength: 3 }), 54 | ], { 55 | blockId: 'actions-2', 56 | }, 57 | ), 58 | image('https://bit.ly/2V2MqwX', 'Jedi power'), 59 | context( 60 | [ 61 | text(':heavy_check_mark: The world better should be', TEXT_FORMAT_MRKDWN), 62 | text(':heavy_exclamation_mark: The air clean is not', TEXT_FORMAT_MRKDWN), 63 | text(':muscle: You all power have to change it', TEXT_FORMAT_MRKDWN), 64 | ], 65 | ), 66 | section( 67 | text('*What great shall we build today*?', TEXT_FORMAT_MRKDWN), { 68 | blockId: 'My-section-block#1', 69 | accessory: overflow('mission-to-take', [ 70 | option('Pyramid', 'pyramid'), 71 | option('Build solar power plant', 'solar-plant'), 72 | option('Sustainable society', 'new-society'), 73 | option('Build rocket to Mars', 'rocket'), 74 | option('Build wall', 'new-society'), 75 | ]), 76 | }, 77 | ), 78 | divider(), 79 | actions( 80 | [ 81 | button('pyramid', 'Pyramid'), 82 | button('solar-plant', 'Solar power plant'), 83 | button('new-society', 'Sustainable society'), 84 | button('rocket', 'Rocket to Mars'), 85 | button('wall', 'Tremendous wall'), 86 | ], 87 | { 88 | blockId: 'actions-1', 89 | }, 90 | ), 91 | section( 92 | text('Make sure you choose the right gear'), { 93 | accessory: multiStaticSelect( 94 | 'gear', 95 | 'Available gear', null, { 96 | optionGroups: optionGroups([ 97 | optionGroup('Water', [ 98 | option('Surf board', 'surf-board'), 99 | option('Dingy', 'dingy'), 100 | option('Canoe', 'canoe'), 101 | ]), 102 | optionGroup('Earth', [ 103 | option('Long board', 'long-board'), 104 | option('Bicycle', 'bicycle'), 105 | option('Mile shoes', 'mile-shoes'), 106 | ]), 107 | optionGroup('Air', [ 108 | option('Kite', 'kite'), 109 | option('Hover board', 'hover-board'), 110 | option('Paraglide', 'paraglide'), 111 | ]), 112 | ]), 113 | initialOptions: [ 114 | // option('Surf board', 'surf-board'), 115 | // option('Long board', 'long-board'), 116 | option('Kite', 'kite'), 117 | ], 118 | 119 | maxSelectedItems: 3, 120 | }, 121 | ), 122 | blockId: 'gear', 123 | }, 124 | ), 125 | section( 126 | text('The power is in numbers!'), { 127 | accessory: multiUsersSelect( 128 | 'members', 129 | 'Choose your squad members', 130 | { 131 | maxSelectedItems: 2, 132 | confirm: confirm( 133 | 'Confirm your *team selection*', 134 | TEXT_FORMAT_MRKDWN, 135 | 'You *will not* be able to change once you depart, choose wisely :thinking_face:', 136 | 'I know what I\'m doing', 137 | 'Let me think again', 138 | ), 139 | }, 140 | ), 141 | blockId: 'teams', 142 | }, 143 | ), 144 | section( 145 | text('Communication is key'), { 146 | accessory: multiConversationsSelect( 147 | 'broadcast', 148 | 'Choose your rescue broadcast', 149 | { maxSelectedItems: 2 }, 150 | ), 151 | blockId: 'conversations', 152 | }, 153 | ), 154 | section( 155 | text('Channel it like a PRO'), { 156 | accessory: multiChannelsSelect( 157 | 'broadcast', 158 | 'Choose your radio channels', 159 | { maxSelectedItems: 2 }, 160 | ), 161 | blockId: 'channels', 162 | }, 163 | ), 164 | ] 165 | 166 | export default createExampleBlocks 167 | -------------------------------------------------------------------------------- /src/view.js: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash.isobject' 2 | import isBoolean from 'lodash.isboolean' 3 | import { text } from './object' 4 | import { isPresentString, typedWithoutUndefined } from './utils' 5 | 6 | /** 7 | * BlockKit View specific errors 8 | * 9 | * @returns {ViewError} 10 | */ 11 | export class ViewError extends Error { 12 | 13 | } 14 | 15 | export const VIEW_MODAL = 'modal' 16 | export const VIEW_HOME = 'home' 17 | export const VIEW_WORKFLOW_STEP = 'workflow_step' 18 | 19 | export const VIEW_TITLE_TEXT_ERROR = 'TitleText has to be a string max 24 characters long' 20 | export const VIEW_NO_BLOCKS_ERROR = 'Provide at least 1 block' 21 | export const VIEW_TOO_MANY_BLOCKS_ERROR = 'Not more than 100 blocks are allowed' 22 | export const VIEW_SUBMIT_TEXT_ERROR = 'Submit text has to be string up to 24 characters long' 23 | export const VIEW_CLOSE_TEXT_ERROR = 'Close text has to be string up to 24 characters long' 24 | export const VIEW_PMD_TOO_LONG_ERROR = 'PrivateMetadata max length is 3000 characters' 25 | export const VIEW_CALLBACK_ID_ERROR = 'CallbackId has to be string with max length 255 characters' 26 | export const VIEW_CLEAR_ON_CLOSE_ERROR = 'clearOnClose has to be a boolean' 27 | export const VIEW_NOTIFY_ON_CLOSE_ERROR = 'notifyOnClose has to be a boolean' 28 | export const VIEW_HASH_ERROR = 'hash has to be a string' 29 | export const VIEW_EXTERNAL_ID_ERROR = 'externalId has to be a string' 30 | 31 | const serializePrivateMetadata = (privateMetadata) => { 32 | if (isObject(privateMetadata)) { 33 | return JSON.stringify(privateMetadata) 34 | } 35 | return privateMetadata 36 | } 37 | 38 | /** 39 | * home tab view 40 | * 41 | * @param [block] blocks - required 1-100 blocks 42 | * @param {object} opts - { privateMetadata, callbackId, externalId } 43 | * 44 | * @returns {object} 45 | */ 46 | 47 | export const home = ( 48 | blocks = [], 49 | { 50 | privateMetadata, 51 | callbackId, 52 | externalId, 53 | } = {}, 54 | ) => { 55 | if (!blocks.length) { 56 | throw new ViewError(VIEW_NO_BLOCKS_ERROR) 57 | } 58 | 59 | if (blocks.length > 100) { 60 | throw new ViewError(VIEW_TOO_MANY_BLOCKS_ERROR) 61 | } 62 | 63 | if (callbackId && !isPresentString(callbackId)) { 64 | throw new ViewError(VIEW_CALLBACK_ID_ERROR) 65 | } 66 | 67 | if (externalId && !isPresentString(externalId, 0)) { 68 | throw new ViewError(VIEW_EXTERNAL_ID_ERROR) 69 | } 70 | let privateMetadataString 71 | 72 | if (privateMetadata) { 73 | privateMetadataString = serializePrivateMetadata(privateMetadata) 74 | 75 | if (!isPresentString(privateMetadataString, 3000)) { 76 | throw new ViewError(VIEW_PMD_TOO_LONG_ERROR) 77 | } 78 | } 79 | 80 | return typedWithoutUndefined(VIEW_HOME, { 81 | blocks, 82 | private_metadata: privateMetadataString || undefined, 83 | callback_id: callbackId, 84 | external_id: externalId, 85 | }) 86 | } 87 | 88 | /** 89 | * modal view 90 | * 91 | * @param string titleText - required title text 92 | * @param [block] blocks - required 1-100 blocks 93 | * @param {object} opts - { closeText, submitText, 94 | * privateMetadata, callbackId, clearOnClose, notifyOnClose, hash, externalId } 95 | * 96 | * @returns {object} 97 | */ 98 | 99 | export const modal = ( 100 | titleText, 101 | blocks = [], 102 | { 103 | closeText, 104 | submitText, 105 | privateMetadata, 106 | callbackId, 107 | clearOnClose, 108 | notifyOnClose, 109 | hash, 110 | externalId, 111 | } = {}, 112 | ) => { 113 | let privateMetadataString 114 | 115 | if (!isPresentString(titleText, 24)) { 116 | throw new ViewError(VIEW_TITLE_TEXT_ERROR) 117 | } 118 | 119 | if (!blocks.length) { 120 | throw new ViewError(VIEW_NO_BLOCKS_ERROR) 121 | } 122 | 123 | if (blocks.length > 100) { 124 | throw new ViewError(VIEW_TOO_MANY_BLOCKS_ERROR) 125 | } 126 | 127 | if (submitText && !isPresentString(submitText, 24)) { 128 | throw new ViewError(VIEW_SUBMIT_TEXT_ERROR) 129 | } 130 | 131 | if (closeText && !isPresentString(closeText, 24)) { 132 | throw new ViewError(VIEW_CLOSE_TEXT_ERROR) 133 | } 134 | 135 | if (privateMetadata) { 136 | privateMetadataString = serializePrivateMetadata(privateMetadata) 137 | 138 | if (!isPresentString(privateMetadataString, 3000)) { 139 | throw new ViewError(VIEW_PMD_TOO_LONG_ERROR) 140 | } 141 | } 142 | 143 | if (callbackId && !isPresentString(callbackId)) { 144 | throw new ViewError(VIEW_CALLBACK_ID_ERROR) 145 | } 146 | 147 | if (clearOnClose && !isBoolean(clearOnClose)) { 148 | throw new ViewError(VIEW_CLEAR_ON_CLOSE_ERROR) 149 | } 150 | 151 | if (notifyOnClose && !isBoolean(notifyOnClose)) { 152 | throw new ViewError(VIEW_NOTIFY_ON_CLOSE_ERROR) 153 | } 154 | 155 | if (hash && !isPresentString(hash, 0)) { 156 | throw new ViewError(VIEW_HASH_ERROR) 157 | } 158 | 159 | if (externalId && !isPresentString(externalId, 0)) { 160 | throw new ViewError(VIEW_EXTERNAL_ID_ERROR) 161 | } 162 | 163 | return typedWithoutUndefined(VIEW_MODAL, { 164 | title: text(titleText), 165 | blocks, 166 | submit: submitText ? text(submitText) : undefined, 167 | close: closeText ? text(closeText) : undefined, 168 | private_metadata: privateMetadataString || undefined, 169 | callback_id: callbackId, 170 | clear_on_close: clearOnClose, 171 | notify_on_close: notifyOnClose, 172 | hash, 173 | external_id: externalId, 174 | }) 175 | } 176 | 177 | export const workflowStep = ( 178 | blocks, 179 | { 180 | privateMetadata, 181 | callbackId, 182 | } = {}, 183 | ) => { 184 | let privateMetadataString 185 | 186 | if (!blocks.length) { 187 | throw new ViewError(VIEW_NO_BLOCKS_ERROR) 188 | } 189 | 190 | if (blocks.length > 100) { 191 | throw new ViewError(VIEW_TOO_MANY_BLOCKS_ERROR) 192 | } 193 | 194 | if (privateMetadata) { 195 | privateMetadataString = serializePrivateMetadata(privateMetadata) 196 | 197 | if (!isPresentString(privateMetadataString, 3000)) { 198 | throw new ViewError(VIEW_PMD_TOO_LONG_ERROR) 199 | } 200 | } 201 | 202 | if (callbackId && !isPresentString(callbackId)) { 203 | throw new ViewError(VIEW_CALLBACK_ID_ERROR) 204 | } 205 | 206 | return typedWithoutUndefined(VIEW_WORKFLOW_STEP, { 207 | blocks, 208 | private_metadata: privateMetadataString || undefined, 209 | callback_id: callbackId, 210 | }) 211 | } 212 | 213 | export default { 214 | modal, 215 | home, 216 | workflowStep, 217 | } 218 | -------------------------------------------------------------------------------- /src/object.js: -------------------------------------------------------------------------------- 1 | import isUndefined from 'lodash.isundefined' 2 | import isBoolean from 'lodash.isboolean' 3 | import isString from 'lodash.isstring' 4 | import { withoutUndefined, isPresentString, typedWithoutUndefined } from './utils' 5 | 6 | // text formats 7 | const TEXT_FORMAT_PLAIN = 'plain_text' 8 | const TEXT_FORMAT_MRKDWN = 'mrkdwn' 9 | 10 | // conversation types 11 | const CONVERSATION_TYPE_IM = 'im' 12 | const CONVERSATION_TYPE_MPIM = 'mpim' 13 | const CONVERSATION_TYPE_PRIVATE = 'private' 14 | const CONVERSATION_TYPE_PUBLIC = 'public' 15 | 16 | const allowedFilterIncludes = [ 17 | CONVERSATION_TYPE_IM, CONVERSATION_TYPE_MPIM, CONVERSATION_TYPE_PUBLIC, CONVERSATION_TYPE_PRIVATE 18 | ] 19 | 20 | /** 21 | * BlockKit Object specific error 22 | * catch it if you're interested 23 | * in low level errors treatment 24 | * 25 | * @returns {undefined} 26 | */ 27 | class ObjectError extends Error { 28 | 29 | } 30 | 31 | /** 32 | * text basic object 33 | * 34 | * @param {string} textValue - required text value 35 | * @param {string} formatting - text format one of `plain_text` or `mrkdwn` 36 | * @param {object} options - { emoji, verbatim } 37 | * 38 | * @returns {object} 39 | */ 40 | const text = (textValue, formatting = TEXT_FORMAT_PLAIN, { emoji, verbatim } = {}) => { 41 | if (!isString(textValue)) { 42 | throw new ObjectError(`There is no \`text\` without textValue, even empty string is a value: ${JSON.stringify(textValue)}`) 43 | } 44 | 45 | if (formatting && ![TEXT_FORMAT_PLAIN, TEXT_FORMAT_MRKDWN].includes(formatting)) { 46 | throw new ObjectError(`Unsupported formatting value: '${formatting}'`) 47 | } 48 | 49 | if (!isUndefined(emoji) && !isBoolean(emoji)) { 50 | throw new ObjectError('Emoji has to be boolean') 51 | } 52 | 53 | if (!isUndefined(verbatim) && !isBoolean(verbatim)) { 54 | throw new ObjectError('Verbatim has to be boolean') 55 | } 56 | 57 | 58 | return typedWithoutUndefined(formatting, { 59 | text: textValue, 60 | emoji: (formatting === TEXT_FORMAT_PLAIN ? emoji : undefined), 61 | verbatim, 62 | }) 63 | } 64 | 65 | /** 66 | * create single option object 67 | * 68 | * @param {string} textValue - required text value 69 | * @param {string} value - required value 70 | * 71 | * @returns {object} 72 | */ 73 | const option = (textValue, value, { descriptionText, url } = {}) => { 74 | let description 75 | 76 | if (!isString(value)) { 77 | throw new ObjectError('Value has to be a string') 78 | } 79 | 80 | if (descriptionText) { 81 | if (!isPresentString(descriptionText, 75)) { 82 | throw new ObjectError('Option description text has to be string, max 75 characters') 83 | } 84 | 85 | description = text(descriptionText) 86 | } 87 | 88 | return withoutUndefined({ 89 | text: text(textValue), 90 | value, 91 | description, 92 | url, 93 | }) 94 | } 95 | 96 | /** 97 | * Single group of options 98 | * 99 | * @param {string} labelText - required label text for option group 100 | * @param {array} options - required array of available options 101 | * 102 | * @returns {undefined} 103 | */ 104 | const optionGroup = (labelText, options = []) => { 105 | if (!isString(labelText)) { 106 | throw new ObjectError('Label has to be a string') 107 | } 108 | 109 | if (!Array.isArray(options)) { 110 | throw new ObjectError('Options have to be an array') 111 | } 112 | 113 | return { 114 | label: text(labelText), 115 | options: [ 116 | ...options, 117 | ], 118 | } 119 | } 120 | 121 | /** 122 | * wrapper structure around mutliple option groups 123 | * 124 | * @param {array} optionGroupObject - required array of option groups 125 | * 126 | * @returns {object} 127 | */ 128 | const optionGroups = (optionGroupObjects) => { 129 | if (!Array.isArray(optionGroupObjects) || !optionGroupObjects.length) { 130 | throw new ObjectError('Option groups have to be not-empty array') 131 | } 132 | 133 | return { 134 | option_groups: [ 135 | ...optionGroupObjects, 136 | ], 137 | } 138 | } 139 | 140 | 141 | /** 142 | * Filter object 143 | * 144 | * @param {array}] include - one or more from CONVERSATIION_TYPE 145 | * @param {boolean} excludeSharedChannels - exclude shared channels 146 | * @param {boolean} excludeBotUsers - exclude bot users 147 | * 148 | * @returns {object} 149 | */ 150 | 151 | const filter = (include, excludeExternalSharedChannels = false, excludeBotUsers = false) => { 152 | if (include) { 153 | if (!Array.isArray(include)) { 154 | throw new ObjectError('Filter include has to be an array') 155 | } 156 | const hasInvalidFields = include.reduce((acc, field) => !allowedFilterIncludes.includes(field), false) 157 | if (hasInvalidFields) { 158 | throw new ObjectError(`Filter include has to be one of ${allowedFilterIncludes.join(', ')}`) 159 | } 160 | } 161 | 162 | if (!isBoolean(excludeBotUsers)) { 163 | throw new ObjectError('Filter excludeBotUsers has to be boolean') 164 | } 165 | if (!isBoolean(excludeExternalSharedChannels)) { 166 | throw new ObjectError('Filter excludeExternalSharedChannels has to be boolean') 167 | } 168 | 169 | return withoutUndefined({ 170 | include, 171 | exclude_external_shared_channels: excludeExternalSharedChannels, 172 | exclude_bot_users: excludeBotUsers, 173 | }) 174 | } 175 | 176 | /** 177 | * confirm dialog 178 | * 179 | * @param {string} titleText - title of confirm dialog 180 | * @param {string} textType - formatting of text value 181 | * @param {string} textValue - value of text for confirm dialog 182 | * @param {string} confirmText - confirm button text value 183 | * @param {string} denyText - deny button text value 184 | * 185 | * @returns {object} 186 | */ 187 | const confirm = (titleText, textType, textValue, confirmText, denyText) => { 188 | if (!isPresentString(titleText, 100)) { 189 | throw new ObjectError('TitleText has to be a string. max 100 characters') 190 | } 191 | 192 | if (!isString(textType)) { 193 | throw new ObjectError('TextType has to be a string') 194 | } 195 | 196 | if (!isPresentString(textValue, 300)) { 197 | throw new ObjectError('TextValue has to be a string, max 300 characters') 198 | } 199 | 200 | if (!isPresentString(confirmText, 30)) { 201 | throw new ObjectError('ConfirmText has to be a string, max 30 characters') 202 | } 203 | 204 | if (!isPresentString(denyText, 30)) { 205 | throw new ObjectError('DenyText has to be a string, max 30 charachters') 206 | } 207 | 208 | return { 209 | title: text(titleText), 210 | text: text(textValue, textType), 211 | confirm: text(confirmText), 212 | deny: text(denyText), 213 | } 214 | } 215 | 216 | export default { 217 | text, 218 | confirm, 219 | option, 220 | optionGroup, 221 | optionGroups, 222 | filter, 223 | } 224 | 225 | export { 226 | text, 227 | confirm, 228 | option, 229 | filter, 230 | optionGroup, 231 | optionGroups, 232 | TEXT_FORMAT_PLAIN, 233 | TEXT_FORMAT_MRKDWN, 234 | CONVERSATION_TYPE_IM, 235 | CONVERSATION_TYPE_MPIM, 236 | CONVERSATION_TYPE_PUBLIC, 237 | CONVERSATION_TYPE_PRIVATE, 238 | } 239 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Block Kit for Slack 2 | 3 | ### This set of functions is following `Slack Block kit documentation` see below 4 | 5 | [blocks (section, divider, image, actions, context, input)](https://api.slack.com/reference/block-kit/blocks) 6 | 7 | [elements (selects, buttons, overflow, datepicker, image, plainTextInput, checkboxes, radiobuttons)](https://api.slack.com/reference/block-kit/block-elements) 8 | 9 | [objects (text, option, confirm)](https://api.slack.com/reference/block-kit/composition-objects) 10 | 11 | [views (modals, app home tab)](https://api.slack.com/reference/surfaces/views) 12 | 13 | 14 | All **required parameters** as specified in Slack Block kit documentation are passed in as arguments. 15 | _Optional_ paramaters are passed in additional argument `opts` object. 16 | 17 | Generic example of required and optional arguments: 18 | 19 | ```javascript 20 | someElement(requiredParam1, requiredParam2, requiredParam3, { optionalParam1, optionalParam2 }) 21 | ``` 22 | 23 | For further details on API see [src](https://github.com/workstreams-ai/slack-block-kit/tree/master/src) folder. 24 | 25 | 26 | For interactive elements - **actionId is always first argument** 27 | Check [Slack docs on support of Interactive elements](https://api.slack.com/reference/block-kit/interactive-components) 28 | 29 | See [examples](https://github.com/workstreams-ai/slack-block-kit/tree/master/examples) folder or example below for quick hints of usage. 30 | 31 | --- 32 | Example of usage: 33 | 34 | ```javascript 35 | import { block, element, object, TEXT_FORMAT_MRKDWN } from 'slack-block-kit' 36 | 37 | const { text, confirm, option, optionGroup, optionGroups } = object 38 | const { 39 | button, overflow, staticSelect, externalSelect, 40 | usersSelect, conversationsSelect, channelsSelect, 41 | datePicker, 42 | } = element 43 | const { section, actions, divider, context, image } = block 44 | 45 | const blocks = [ 46 | section( 47 | text( 48 | '**\nLet\'s *rock* and *roll* towards better future!', 49 | TEXT_FORMAT_MRKDWN 50 | ), { 51 | fields: [ 52 | text(':heavy_check_mark: *`get stuff done`* :tada:', TEXT_FORMAT_MRKDWN), 53 | text(':arrow_double_up: *Priority* over *deadline* :timer_clock:', TEXT_FORMAT_MRKDWN) 54 | ], 55 | accessory: datePicker('mission-start', { 56 | placeholderText: 'Select a date', 57 | initialDate: '2019-03-23' 58 | }) 59 | }), 60 | actions( 61 | [ 62 | usersSelect('jedi-knight', 'Choose your Jedi'), 63 | conversationsSelect('commanding-team', 'Commanding team'), 64 | channelsSelect('space-ship', 'Choose your squad'), 65 | staticSelect('weapon', 'Choose your weapon', [ 66 | option('Light sabre', 'light-sabre'), 67 | option('PAC cannon', 'pac-cannon'), 68 | option('Dark matter', 'dark-matter'), 69 | option('Telepathy', 'telepathy'), 70 | ], { 71 | initialOption: option('Light sabre', 'light-sabre'), 72 | confirm: confirm( 73 | 'Confirm your weapon', 74 | TEXT_FORMAT_MRKDWN, 75 | 'You *will not* be able to change your weapon, choose wisely :thinking_face:', 76 | 'Choose', 77 | 'Let me think again', 78 | ), 79 | }), 80 | externalSelect('special-weapon', 'Special weapon', { minQueryLength: 3 }), 81 | ], { 82 | blockId: 'actions-2', 83 | } 84 | ), 85 | image('https://bit.ly/2V2MqwX', 'Jedi power'), 86 | context( 87 | [ 88 | text(':heavy_check_mark: The world better should be', TEXT_FORMAT_MRKDWN), 89 | text(':heavy_exclamation_mark: The air clean is not', TEXT_FORMAT_MRKDWN), 90 | text(':muscle: You all power have to change it', TEXT_FORMAT_MRKDWN), 91 | ] 92 | ), 93 | section( 94 | text('*What great shall we build today*?', TEXT_FORMAT_MRKDWN), { 95 | blockId: 'My-section-block#1', 96 | accessory: overflow('mission-to-take', [ 97 | option('Pyramid', 'pyramid',), 98 | option('Build solar power plant', 'solar-plant'), 99 | option('Sustainable society', 'new-society', ), 100 | option('Build rocket to Mars', 'rocket'), 101 | option('Build wall', 'new-society'), 102 | ]) 103 | }), 104 | divider(), 105 | actions( 106 | [ 107 | button('pyramid','Pyramid'), 108 | button('solar-plant', 'Solar power plant'), 109 | button('new-society', 'Sustainable society'), 110 | button('rocket', 'Rocket to Mars'), 111 | button('wall', 'Tremendous wall'), 112 | ],{ 113 | blockId: 'actions-1', 114 | }), 115 | section( 116 | text('Make sure you choose the right gear'), { 117 | accessory: multiStaticSelect( 118 | 'gear', 119 | 'Available gear', null, { 120 | optionGroups: optionGroups([ 121 | optionGroup('Water', [ 122 | option('Surf board', 'surf-board'), 123 | option('Dingy', 'dingy'), 124 | option('Canoe', 'canoe'), 125 | ]), 126 | optionGroup('Earth', [ 127 | option('Long board', 'long-board'), 128 | option('Bicycle', 'bicycle'), 129 | option('Mile shoes', 'mile-shoes'), 130 | ]), 131 | optionGroup('Air', [ 132 | option('Kite', 'kite'), 133 | option('Hover board', 'hover-board'), 134 | option('Paraglide', 'paraglide'), 135 | ]), 136 | ]), 137 | initialOptions: [ 138 | // option('Surf board', 'surf-board'), 139 | // option('Long board', 'long-board'), 140 | option('Kite', 'kite'), 141 | ], 142 | 143 | maxSelectedItems: 3, 144 | }, 145 | ), 146 | blockId: 'gear', 147 | }, 148 | ), 149 | section( 150 | text('The power is in numbers!'), { 151 | accessory: multiUsersSelect( 152 | 'members', 153 | 'Choose your squad members', 154 | { 155 | maxSelectedItems: 2, 156 | confirm: confirm( 157 | 'Confirm your *team selection*', 158 | TEXT_FORMAT_MRKDWN, 159 | 'You *will not* be able to change once you depart, choose wisely :thinking_face:', 160 | 'I know what I\'m doing', 161 | 'Let me think again', 162 | ), 163 | }, 164 | ), 165 | blockId: 'teams', 166 | }, 167 | ), 168 | section( 169 | text('Communication is key'), { 170 | accessory: multiConversationsSelect( 171 | 'broadcast', 172 | 'Choose your rescue broadcast', 173 | { maxSelectedItems: 2 }, 174 | ), 175 | blockId: 'conversations', 176 | }, 177 | ), 178 | section( 179 | text('Channel it like a PRO'), { 180 | accessory: multiChannelsSelect( 181 | 'broadcast', 182 | 'Choose your radio channels', 183 | { maxSelectedItems: 2 }, 184 | ), 185 | blockId: 'channels', 186 | }, 187 | ), 188 | ] 189 | ``` 190 | 191 | Will produce following message 192 | 193 | 194 | ![Slack Block Kit message example](https://s3-us-west-2.amazonaws.com/files.workstreams.ai/public/block-kit-agile-jedis-v2.jpg) 195 | -------------------------------------------------------------------------------- /test/block-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | section, 4 | divider, 5 | image, 6 | actions, 7 | input, 8 | header, 9 | context as blockContext, 10 | BLOCK_SECTION, 11 | BLOCK_DIVIDER, 12 | BLOCK_IMAGE, 13 | BLOCK_ACTIONS, 14 | BLOCK_CONTEXT, 15 | BLOCK_INPUT, 16 | BLOCK_HEADER, 17 | buildBlock, 18 | } from '../src/block' 19 | 20 | import { 21 | text, TEXT_FORMAT_MRKDWN, TEXT_FORMAT_PLAIN, 22 | } from '../src/object' 23 | import { button, usersSelect, image as elImage } from '../src/element' 24 | 25 | describe('BlockKit blocks', () => { 26 | context('blockBuilder', () => { 27 | it('should throw error on unsupported block type', () => { 28 | expect(() => buildBlock('checkbox')).to.throw('Unsupported block type \'checkbox\'') 29 | }) 30 | }) 31 | 32 | context('Divider', () => { 33 | it('should return correct divider structure', () => { 34 | const expectedBlock = { 35 | type: BLOCK_DIVIDER, 36 | } 37 | const dividerBlock = divider() 38 | expect(dividerBlock).deep.eql(expectedBlock) 39 | }) 40 | }) 41 | 42 | context('Section', () => { 43 | const actionId = 'section-action' 44 | 45 | it('should build section block with text, fields and accessory', () => { 46 | const blockId = 'B1234' 47 | const expectedBlock = { 48 | type: BLOCK_SECTION, 49 | block_id: blockId, 50 | text: { 51 | type: TEXT_FORMAT_PLAIN, 52 | text: 'simple message', 53 | }, 54 | fields: [ 55 | text('field1'), 56 | text('field2'), 57 | ], 58 | accessory: button(actionId, 'click me'), 59 | } 60 | 61 | const sectionBlock = section( 62 | text('simple message'), 63 | { blockId: 'B1234', fields: expectedBlock.fields, accessory: expectedBlock.accessory }, 64 | ) 65 | 66 | expect(sectionBlock).deep.eql(expectedBlock) 67 | }) 68 | 69 | it('should throw error on invalid text object', () => { 70 | expect(() => section()).to.throw('Section has to contain text object') 71 | expect(() => section('hello')).to.throw('Section has to contain text object') 72 | expect(() => section({ type: 'not-supported-type', text: 'hello world' })).to.throw('Section has to contain text object') 73 | }) 74 | }) 75 | 76 | context('context', () => { 77 | it('should return context structure with text object', () => { 78 | const textObject = { 79 | type: TEXT_FORMAT_MRKDWN, 80 | text: '*hello world*', 81 | } 82 | const expectedBlock = { 83 | type: BLOCK_CONTEXT, 84 | elements: [ 85 | textObject, 86 | ], 87 | } 88 | 89 | const contextBlock = blockContext([textObject]) 90 | expect(contextBlock).deep.eql(expectedBlock) 91 | }) 92 | it('should throw error on invalid elements param', () => { 93 | expect(() => blockContext()).to.throw('Context needs to have an array of elements') 94 | expect(() => blockContext([])).to.throw('Context needs to have an array of elements') 95 | expect(() => blockContext({})).to.throw('Context needs to have an array of elements') 96 | 97 | expect(() => blockContext(['hello'])).to.throw('Context elements can be only image or text') 98 | expect(() => blockContext([undefined, undefined])).to.throw('Context elements can be only image or text') 99 | }) 100 | }) 101 | 102 | context('actions', () => { 103 | it('should build basic actions with users select', () => { 104 | const usersSelectElement = usersSelect('my-action', 'Select user') 105 | const expectedBlock = { 106 | type: BLOCK_ACTIONS, 107 | elements: [ 108 | usersSelectElement, 109 | ], 110 | } 111 | const actionsBlock = actions([usersSelectElement]) 112 | expect(actionsBlock).deep.eql(expectedBlock) 113 | }) 114 | 115 | it('should throw error on invalid elements', () => { 116 | expect(() => actions()).to.throw('Actions require an array of elements') 117 | expect(() => actions({})).to.throw('Actions require an array of elements') 118 | 119 | const actionId = 'click-me' 120 | const actionButtons = [ 121 | button(actionId, 'click me'), 122 | button(actionId, 'click me'), 123 | button(actionId, 'click me'), 124 | button(actionId, 'click me'), 125 | button(actionId, 'click me'), 126 | button(actionId, 'click me'), 127 | ] 128 | expect(() => actions(actionButtons)).to.throw('Each actions block can have only up to 5 elements') 129 | expect(() => actions([elImage('some-url', 'fake-image')])).to.throw('Invalid element for actions') 130 | expect(() => actions([undefined])).to.throw('Invalid element for actions') 131 | }) 132 | }) 133 | context('Image block', () => { 134 | const imageUrl = 'https://i.stack.imgur.com/hmazD.png' 135 | const altText = 'Funny lol-cat' 136 | 137 | it('should return minimal image block', () => { 138 | const expectedBlock = { 139 | type: BLOCK_IMAGE, 140 | image_url: imageUrl, 141 | alt_text: altText, 142 | } 143 | expect(image(imageUrl, altText)) 144 | .deep.eql(expectedBlock) 145 | }) 146 | 147 | it('should return image block with all options', () => { 148 | const titleText = 'Funny lol-cat' 149 | const expectedBlock = { 150 | type: BLOCK_IMAGE, 151 | image_url: imageUrl, 152 | alt_text: altText, 153 | title: text(titleText), 154 | block_id: 'B1234', 155 | } 156 | expect(image(imageUrl, altText, { titleText, blockId: 'B1234' })) 157 | .deep.eql(expectedBlock) 158 | }) 159 | }) 160 | 161 | context('input blocks', () => { 162 | const labelText = 'some important input' 163 | const element = usersSelect('my-action', 'Select user') 164 | const hintText = 'some hint stuff' 165 | const optional = true 166 | const blockId = 'my-block-id' 167 | const basicExpectedOutput = { 168 | type: BLOCK_INPUT, 169 | label: text(labelText), 170 | element, 171 | } 172 | 173 | it('should return basic mandatory input', () => { 174 | expect(input(labelText, element)).eql(basicExpectedOutput) 175 | }) 176 | 177 | it('should return full input configured', () => { 178 | const expectedOutput = { 179 | ...basicExpectedOutput, 180 | block_id: blockId, 181 | hint: text(hintText), 182 | optional: true, 183 | } 184 | 185 | expect(input(labelText, element, { hintText, optional, blockId })).eql(expectedOutput) 186 | }) 187 | 188 | it('should prevent wrong elements', () => { 189 | expect(() => input(labelText, button('click-me', 'click-me'))).to.throw('button is not supported input element') 190 | }) 191 | 192 | it('should prevent no label input ', () => { 193 | expect(() => input()).to.throw('Input block needs to have a labelText') 194 | }) 195 | }) 196 | 197 | context('header blocks', () => { 198 | const headerText = 'some important header' 199 | const blockId = 'my-block-id' 200 | const basicExpectedOutput = { 201 | type: BLOCK_HEADER, 202 | text: text(headerText), 203 | } 204 | 205 | it('should return basic mandatory input', () => { 206 | expect(header(headerText)).eql(basicExpectedOutput) 207 | }) 208 | 209 | it('should return full input configured', () => { 210 | const expectedOutput = { 211 | ...basicExpectedOutput, 212 | block_id: blockId, 213 | } 214 | 215 | expect(header(headerText, { blockId })).eql(expectedOutput) 216 | }) 217 | 218 | it('should prevent no header text', () => { 219 | expect(() => header()).to.throw('Header block needs to have headerText') 220 | }) 221 | }) 222 | 223 | }) 224 | -------------------------------------------------------------------------------- /examples/block-kit-message-output.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "text" : { 4 | "type" : "mrkdwn", 5 | "text" : "**\nLet's *rock* and *roll* towards better future!" 6 | }, 7 | "fields" : [ 8 | { 9 | "type" : "mrkdwn", 10 | "text" : ":heavy_check_mark: *`get stuff done`* :tada:" 11 | }, 12 | { 13 | "type" : "mrkdwn", 14 | "text" : ":arrow_double_up: *Priority* over *deadline* :timer_clock:" 15 | } 16 | ], 17 | "accessory" : { 18 | "placeholder" : { 19 | "text" : "Select a date", 20 | "type" : "plain_text" 21 | }, 22 | "action_id" : "mission-start", 23 | "type" : "datepicker", 24 | "initial_date" : "2019-03-23" 25 | }, 26 | "type" : "section" 27 | }, 28 | { 29 | "elements" : [ 30 | { 31 | "placeholder" : { 32 | "type" : "plain_text", 33 | "text" : "Choose your Jedi" 34 | }, 35 | "type" : "users_select", 36 | "action_id" : "jedi-knight" 37 | }, 38 | { 39 | "placeholder" : { 40 | "type" : "plain_text", 41 | "text" : "Commanding team" 42 | }, 43 | "type" : "conversations_select", 44 | "action_id" : "commanding-team" 45 | }, 46 | { 47 | "placeholder" : { 48 | "text" : "Choose your squad", 49 | "type" : "plain_text" 50 | }, 51 | "action_id" : "space-ship", 52 | "type" : "channels_select" 53 | }, 54 | { 55 | "confirm" : { 56 | "text" : { 57 | "text" : "You *will not* be able to change your weapon, choose wisely :thinking_face:", 58 | "type" : "mrkdwn" 59 | }, 60 | "title" : { 61 | "text" : "Confirm your weapon", 62 | "type" : "plain_text" 63 | }, 64 | "deny" : { 65 | "type" : "plain_text", 66 | "text" : "Let me think again" 67 | }, 68 | "confirm" : { 69 | "text" : "Choose", 70 | "type" : "plain_text" 71 | } 72 | }, 73 | "options" : [ 74 | { 75 | "text" : { 76 | "text" : "Light sabre", 77 | "type" : "plain_text" 78 | }, 79 | "value" : "light-sabre" 80 | }, 81 | { 82 | "value" : "pac-cannon", 83 | "text" : { 84 | "type" : "plain_text", 85 | "text" : "PAC cannon" 86 | } 87 | }, 88 | { 89 | "text" : { 90 | "text" : "Dark matter", 91 | "type" : "plain_text" 92 | }, 93 | "value" : "dark-matter" 94 | }, 95 | { 96 | "value" : "telepathy", 97 | "text" : { 98 | "text" : "Telepathy", 99 | "type" : "plain_text" 100 | } 101 | } 102 | ], 103 | "action_id" : "weapon", 104 | "type" : "static_select", 105 | "initial_option" : { 106 | "value" : "light-sabre", 107 | "text" : { 108 | "text" : "Light sabre", 109 | "type" : "plain_text" 110 | } 111 | }, 112 | "placeholder" : { 113 | "text" : "Choose your weapon", 114 | "type" : "plain_text" 115 | } 116 | }, 117 | { 118 | "placeholder" : { 119 | "text" : "Special weapon", 120 | "type" : "plain_text" 121 | }, 122 | "type" : "external_select", 123 | "action_id" : "special-weapon", 124 | "min_query_length" : 3 125 | } 126 | ], 127 | "type" : "actions", 128 | "block_id" : "actions-2" 129 | }, 130 | { 131 | "image_url" : "https://bit.ly/2V2MqwX", 132 | "alt_text" : "Jedi power", 133 | "type" : "image" 134 | }, 135 | { 136 | "type" : "context", 137 | "elements" : [ 138 | { 139 | "text" : ":heavy_check_mark: The world better should be", 140 | "type" : "mrkdwn" 141 | }, 142 | { 143 | "type" : "mrkdwn", 144 | "text" : ":heavy_exclamation_mark: The air clean is not" 145 | }, 146 | { 147 | "text" : ":muscle: You all power have to change it", 148 | "type" : "mrkdwn" 149 | } 150 | ] 151 | }, 152 | { 153 | "block_id" : "My-section-block#1", 154 | "text" : { 155 | "type" : "mrkdwn", 156 | "text" : "*What great shall we build today*?" 157 | }, 158 | "accessory" : { 159 | "type" : "overflow", 160 | "options" : [ 161 | { 162 | "value" : "pyramid", 163 | "text" : { 164 | "type" : "plain_text", 165 | "text" : "Pyramid" 166 | } 167 | }, 168 | { 169 | "text" : { 170 | "type" : "plain_text", 171 | "text" : "Build solar power plant" 172 | }, 173 | "value" : "solar-plant" 174 | }, 175 | { 176 | "value" : "new-society", 177 | "text" : { 178 | "type" : "plain_text", 179 | "text" : "Sustainable society" 180 | } 181 | }, 182 | { 183 | "text" : { 184 | "text" : "Build rocket to Mars", 185 | "type" : "plain_text" 186 | }, 187 | "value" : "rocket" 188 | }, 189 | { 190 | "text" : { 191 | "type" : "plain_text", 192 | "text" : "Build wall" 193 | }, 194 | "value" : "new-society" 195 | } 196 | ], 197 | "action_id" : "mission-to-take" 198 | }, 199 | "type" : "section" 200 | }, 201 | { 202 | "type" : "divider" 203 | }, 204 | { 205 | "elements" : [ 206 | { 207 | "text" : { 208 | "type" : "plain_text", 209 | "text" : "Pyramid" 210 | }, 211 | "type" : "button", 212 | "action_id" : "pyramid" 213 | }, 214 | { 215 | "text" : { 216 | "text" : "Solar power plant", 217 | "type" : "plain_text" 218 | }, 219 | "type" : "button", 220 | "action_id" : "solar-plant" 221 | }, 222 | { 223 | "type" : "button", 224 | "action_id" : "new-society", 225 | "text" : { 226 | "type" : "plain_text", 227 | "text" : "Sustainable society" 228 | } 229 | }, 230 | { 231 | "action_id" : "rocket", 232 | "type" : "button", 233 | "text" : { 234 | "type" : "plain_text", 235 | "text" : "Rocket to Mars" 236 | } 237 | }, 238 | { 239 | "text" : { 240 | "type" : "plain_text", 241 | "text" : "Tremendous wall" 242 | }, 243 | "action_id" : "wall", 244 | "type" : "button" 245 | } 246 | ], 247 | "type" : "actions", 248 | "block_id" : "actions-1" 249 | } 250 | ] 251 | -------------------------------------------------------------------------------- /src/block.js: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash.isobject' 2 | import omit from 'lodash.omit' 3 | import { isPresentString, typedWithoutUndefined } from './utils' 4 | 5 | import { 6 | text, TEXT_FORMAT_MRKDWN, TEXT_FORMAT_PLAIN, 7 | } from './object' 8 | 9 | import { 10 | ELEMENT_IMAGE, 11 | ELEMENT_BUTTON, 12 | ELEMENT_OVERFLOW, 13 | ELEMENT_DATEPICKER, 14 | ELEMENT_TIMEPICKER, 15 | ELEMENT_USERS_SELECT, 16 | ELEMENT_STATIC_SELECT, 17 | ELEMENT_CHANNELS_SELECT, 18 | ELEMENT_CONVERSATIONS_SELECT, 19 | ELEMENT_EXTERNAL_SELECT, 20 | ELEMENT_STATIC_MULTI_SELECT, 21 | ELEMENT_EXTERNAL_MULTI_SELECT, 22 | ELEMENT_USERS_MULTI_SELECT, 23 | ELEMENT_CONVERSATIONS_MULTI_SELECT, 24 | ELEMENT_CHANNELS_MULTI_SELECT, 25 | ELEMENT_PLAIN_TEXT_INPUT, 26 | ELEMENT_RADIO_BUTTONS, 27 | ELEMENT_CHECKBOXES, 28 | image as elImage, 29 | } from './element' 30 | 31 | // block types 32 | const BLOCK_SECTION = 'section' 33 | const BLOCK_DIVIDER = 'divider' 34 | const BLOCK_IMAGE = 'image' 35 | const BLOCK_ACTIONS = 'actions' 36 | const BLOCK_CONTEXT = 'context' 37 | const BLOCK_INPUT = 'input' 38 | const BLOCK_HEADER = 'header' 39 | 40 | const SUPPORTED_BLOCKS = [ 41 | BLOCK_SECTION, BLOCK_DIVIDER, BLOCK_CONTEXT, BLOCK_ACTIONS, BLOCK_IMAGE, BLOCK_INPUT, BLOCK_HEADER, 42 | ] 43 | 44 | const VALID_CONTEXT_ELEMENTS = [ELEMENT_IMAGE, TEXT_FORMAT_MRKDWN, TEXT_FORMAT_PLAIN] 45 | const VALID_ACTIONS_ELEMENTS = [ 46 | ELEMENT_BUTTON, ELEMENT_OVERFLOW, ELEMENT_DATEPICKER, ELEMENT_USERS_SELECT, 47 | ELEMENT_STATIC_SELECT, ELEMENT_CHANNELS_SELECT, ELEMENT_CONVERSATIONS_SELECT, 48 | ELEMENT_EXTERNAL_SELECT, ELEMENT_CHECKBOXES, ELEMENT_RADIO_BUTTONS, ELEMENT_TIMEPICKER, 49 | ] 50 | 51 | const VALID_INPUT_ELEMENTS = [ 52 | ELEMENT_PLAIN_TEXT_INPUT, ELEMENT_STATIC_SELECT, ELEMENT_EXTERNAL_SELECT, ELEMENT_USERS_SELECT, 53 | ELEMENT_CONVERSATIONS_SELECT, ELEMENT_CHANNELS_SELECT, 54 | ELEMENT_STATIC_MULTI_SELECT, ELEMENT_EXTERNAL_MULTI_SELECT, ELEMENT_USERS_MULTI_SELECT, 55 | ELEMENT_CHANNELS_MULTI_SELECT, ELEMENT_CONVERSATIONS_MULTI_SELECT, ELEMENT_USERS_MULTI_SELECT, 56 | ELEMENT_CHECKBOXES, ELEMENT_RADIO_BUTTONS, ELEMENT_DATEPICKER, ELEMENT_TIMEPICKER, 57 | ] 58 | 59 | /** 60 | * Block specific error 61 | * catch it if you can 62 | * nothing except name 63 | * 64 | * @returns BlockError 65 | */ 66 | class BlockError extends Error { 67 | 68 | } 69 | 70 | const isValidBlockType = (type) => { 71 | if (!SUPPORTED_BLOCKS.includes(type)) { 72 | throw new BlockError(`Unsupported block type '${type}'`) 73 | } 74 | return true 75 | } 76 | 77 | const isValidHeaderBlock = headerText => { 78 | if (!isPresentString(headerText, 3000)) { 79 | throw new BlockError('Header block needs to have headerText') 80 | } 81 | return true 82 | } 83 | 84 | const isValidInputBlock = (labelText, element) => { 85 | if (!labelText) { 86 | throw new BlockError('Input block needs to have a labelText') 87 | } 88 | 89 | if (!VALID_INPUT_ELEMENTS.includes(element.type)) { 90 | throw new BlockError(`${element.type} is not supported input element`) 91 | } 92 | return true 93 | } 94 | 95 | const buildBlock = (type, props = {}) => 96 | isValidBlockType(type) && typedWithoutUndefined(type, { 97 | ...omit(props, ['blockId']), 98 | block_id: props.blockId, 99 | }) 100 | 101 | const isValidSection = (sectionText) => { 102 | if ( 103 | !sectionText 104 | || !isObject(sectionText) 105 | || ![TEXT_FORMAT_MRKDWN, TEXT_FORMAT_PLAIN].includes(sectionText.type) 106 | ) { 107 | throw new BlockError('Section has to contain text object') 108 | } 109 | // T0D0 add checks for accessory and fields elements 110 | return true 111 | } 112 | 113 | const isValidContext = (elements) => { 114 | if (!elements || !Array.isArray(elements) || elements.length === 0) { 115 | throw new Error('Context needs to have an array of elements') 116 | } 117 | 118 | if (elements.some(({ type } = {}) => !VALID_CONTEXT_ELEMENTS.includes(type))) { 119 | throw new Error('Context elements can be only image or text') 120 | } 121 | 122 | return true 123 | } 124 | const isValidActions = (elements) => { 125 | if (!elements || !Array.isArray(elements)) { 126 | throw new BlockError('Actions require an array of elements') 127 | } 128 | if (elements.length > 5) { 129 | throw new BlockError('Each actions block can have only up to 5 elements') 130 | } 131 | if (elements.some(({ type } = {}) => !VALID_ACTIONS_ELEMENTS.includes(type))) { 132 | throw new BlockError('Invalid element for actions') 133 | } 134 | 135 | return true 136 | } 137 | 138 | 139 | /** 140 | * section block 141 | * 142 | * @param {object} text - required text object 143 | * @param {object} opts - { blockId, fields, accessory } 144 | * 145 | * @returns {object} 146 | */ 147 | const section = (sectionText, { blockId, fields, accessory } = {}) => 148 | isValidSection(sectionText, { blockId, fields, accessory }) && buildBlock(BLOCK_SECTION, { 149 | text: sectionText, 150 | blockId, 151 | fields, 152 | accessory, 153 | }) 154 | 155 | /** 156 | * Divider block 157 | * 158 | * @param {object} opts - { blockId } 159 | * 160 | * @returns {object} 161 | */ 162 | const divider = ({ blockId } = {}) => buildBlock(BLOCK_DIVIDER, { blockId }) 163 | 164 | /** 165 | * Context block 166 | * valid elements are 167 | * ELEMENT_IMAGE, TEXT_FORMAT_MRKDWN, TEXT_FORMAT_PLAIN 168 | * 169 | * @param {object[]} - required array of valid elements 170 | * @param {object} - { blockId } 171 | * 172 | * @returns {object} 173 | */ 174 | const context = (elements, { blockId } = {}) => 175 | isValidContext(elements) && buildBlock(BLOCK_CONTEXT, { blockId, elements }) 176 | 177 | /** 178 | * Actions block - can contain up to 5 elements 179 | * Valid elements are: 180 | * ELEMENT_BUTTON, ELEMENT_OVERFLOW, ELEMENT_DATEPICKER, ELEMENT_USERS_SELECT, 181 | * ELEMENT_STATIC_SELECT, ELEMENT_CHANNELS_SELECT, 182 | * ELEMENT_CONVERSATIONS_SELECT, ELEMENT_EXTERNAL_SELECT 183 | * 184 | * @param {{object[]} elements - required array of valid elements 185 | * @param {object} opts - { blockId } 186 | * 187 | * @returns {object} 188 | */ 189 | const actions = (elements, { blockId } = {}) => 190 | isValidActions(elements) && buildBlock(BLOCK_ACTIONS, { blockId, elements }) 191 | 192 | /** 193 | * Image block 194 | * 195 | * @param {string} imageUrl - required image Url 196 | * @param {string} altText - required alt text 197 | * @param {object} opts - { titleText, blockId } 198 | * 199 | * @returns {object} 200 | */ 201 | const image = (imageUrl, altText, { titleText, blockId } = {}) => buildBlock(BLOCK_IMAGE, { 202 | ...elImage(imageUrl, altText), 203 | blockId, 204 | title: titleText ? text(titleText) : undefined, 205 | }) 206 | 207 | /** 208 | * Inputblock 209 | * 210 | * @param {string} labelText - required label text 211 | * @param {object} element - required input element 212 | * @param {object} opts - { hintText, blockId, optional } 213 | * 214 | * @returns {object} 215 | */ 216 | const input = (labelText, element, { hintText, blockId, optional } = {}) => 217 | isValidInputBlock(labelText, element) && buildBlock(BLOCK_INPUT, { 218 | label: text(labelText), 219 | element, 220 | blockId, 221 | hint: hintText ? text(hintText) : undefined, 222 | optional, 223 | }) 224 | 225 | /** 226 | * HeaderBlock 227 | * 228 | * @param {string} headerText - required header text 229 | * @param {object} opts - { blockId } 230 | * 231 | * @returns {object} 232 | */ 233 | const header = (headerText,{ blockId } = {}) => 234 | isValidHeaderBlock(headerText) && buildBlock(BLOCK_HEADER, { 235 | text: text(headerText), 236 | blockId, 237 | }) 238 | 239 | 240 | 241 | export default { 242 | section, 243 | divider, 244 | image, 245 | actions, 246 | context, 247 | input, 248 | header, 249 | } 250 | 251 | export { 252 | section, 253 | divider, 254 | image, 255 | actions, 256 | context, 257 | input, 258 | header, 259 | buildBlock, 260 | BLOCK_SECTION, 261 | BLOCK_DIVIDER, 262 | BLOCK_IMAGE, 263 | BLOCK_ACTIONS, 264 | BLOCK_CONTEXT, 265 | BLOCK_INPUT, 266 | BLOCK_HEADER, 267 | } 268 | -------------------------------------------------------------------------------- /test/view-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | modal, home, workflowStep, 4 | VIEW_MODAL, VIEW_HOME, VIEW_WORKFLOW_STEP, 5 | VIEW_TITLE_TEXT_ERROR, 6 | VIEW_NO_BLOCKS_ERROR, 7 | VIEW_TOO_MANY_BLOCKS_ERROR, 8 | VIEW_SUBMIT_TEXT_ERROR, 9 | VIEW_CLOSE_TEXT_ERROR, 10 | VIEW_PMD_TOO_LONG_ERROR, 11 | VIEW_CALLBACK_ID_ERROR, 12 | VIEW_CLEAR_ON_CLOSE_ERROR, 13 | VIEW_NOTIFY_ON_CLOSE_ERROR, 14 | VIEW_HASH_ERROR, 15 | VIEW_EXTERNAL_ID_ERROR, 16 | } from '../src/view' 17 | import { text } from '../src/object' 18 | import { divider } from '../src/block' 19 | 20 | describe('Modal view', () => { 21 | const titleText = 'Test modal' 22 | const submitText = 'Do it' 23 | const closeText = 'Don\'t do it' 24 | const dummyBlocks = [ 25 | divider(), 26 | ] 27 | const privateMetadataObject = { 28 | some: 'data', 29 | other: 'values', 30 | } 31 | const callbackId = 'validCallbackId' 32 | const clearOnClose = true 33 | const notifyOnClose = true 34 | const hash = 'my-little-hash' 35 | const externalId = 'external-id' 36 | 37 | context('Modal view', () => { 38 | it('should produce basic modal without options', () => { 39 | const expectedObject = { 40 | type: VIEW_MODAL, 41 | title: text(titleText), 42 | blocks: dummyBlocks, 43 | } 44 | 45 | expect(modal(titleText, dummyBlocks)).eql(expectedObject) 46 | }) 47 | 48 | it('should produce full modal with all options', () => { 49 | const expectedObject = { 50 | type: VIEW_MODAL, 51 | title: text(titleText), 52 | blocks: dummyBlocks, 53 | private_metadata: JSON.stringify(privateMetadataObject), 54 | submit: text(submitText), 55 | close: text(closeText), 56 | callback_id: callbackId, 57 | clear_on_close: clearOnClose, 58 | notify_on_close: notifyOnClose, 59 | hash, 60 | external_id: externalId, 61 | } 62 | 63 | const result = modal( 64 | titleText, 65 | dummyBlocks, 66 | { 67 | submitText, 68 | closeText, 69 | privateMetadata: privateMetadataObject, 70 | callbackId, 71 | clearOnClose, 72 | notifyOnClose, 73 | hash, 74 | externalId, 75 | }, 76 | ) 77 | 78 | expect(result).eql(expectedObject) 79 | }) 80 | 81 | it('should produce full modal with all string private metadata', () => { 82 | const expectedObject = { 83 | type: VIEW_MODAL, 84 | title: text(titleText), 85 | blocks: dummyBlocks, 86 | private_metadata: 'someVar=someValue', 87 | } 88 | 89 | const result = modal( 90 | titleText, 91 | dummyBlocks, 92 | { 93 | privateMetadata: 'someVar=someValue', 94 | }, 95 | ) 96 | 97 | expect(result).eql(expectedObject) 98 | }) 99 | 100 | it('should prevent modal without title', () => { 101 | expect(() => modal()).to.throw(VIEW_TITLE_TEXT_ERROR) 102 | }) 103 | 104 | it('should prevent modal with too long title', () => { 105 | expect(() => modal('This title is way too long to pass 24 characters limit')).to.throw(VIEW_TITLE_TEXT_ERROR) 106 | }) 107 | 108 | it('should prevent modal with too long submit', () => { 109 | expect(() => modal( 110 | titleText, 111 | dummyBlocks, 112 | { 113 | submitText: 'This title is way too long to pass 24 characters limit', 114 | }, 115 | )).to.throw(VIEW_SUBMIT_TEXT_ERROR) 116 | }) 117 | 118 | it('should prevent modal with too long close', () => { 119 | expect(() => modal( 120 | titleText, 121 | dummyBlocks, 122 | { 123 | closeText: 'This title is way too long to pass 24 characters limit', 124 | }, 125 | )).to.throw(VIEW_CLOSE_TEXT_ERROR) 126 | }) 127 | 128 | it('should prevent no blocks case', () => { 129 | expect(() => modal(titleText)).to.throw(VIEW_NO_BLOCKS_ERROR) 130 | }) 131 | 132 | it('should prevent more than 100 blocks case', () => { 133 | const tooManyBlocks = [] 134 | for (let i = 0; i <= 100; i += 1) { 135 | tooManyBlocks.push(divider()) 136 | } 137 | expect(() => modal(titleText, tooManyBlocks)).to.throw(VIEW_TOO_MANY_BLOCKS_ERROR) 138 | }) 139 | 140 | it('should prevent too large metadata', () => { 141 | const tooManyBlocks = [] 142 | for (let i = 0; i <= 500; i += 1) { 143 | tooManyBlocks.push(divider()) 144 | } 145 | 146 | expect(() => modal(titleText, dummyBlocks, { 147 | privateMetadata: tooManyBlocks, 148 | })).to.throw(VIEW_PMD_TOO_LONG_ERROR) 149 | }) 150 | 151 | it('should prevent no-string callbackId', () => { 152 | expect(() => modal(titleText, dummyBlocks, { 153 | callbackId: true, 154 | })).to.throw(VIEW_CALLBACK_ID_ERROR) 155 | }) 156 | 157 | it('should prevent too long callbackId', () => { 158 | expect(() => modal(titleText, dummyBlocks, { 159 | callbackId: 'aasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdsd', 160 | })).to.throw(VIEW_CALLBACK_ID_ERROR) 161 | }) 162 | 163 | it('should prevent non-boolean clearOnClose', () => { 164 | expect(() => modal(titleText, dummyBlocks, { 165 | clearOnClose: 'abc', 166 | })).to.throw(VIEW_CLEAR_ON_CLOSE_ERROR) 167 | }) 168 | 169 | it('should prevent non-boolean clearOnClose', () => { 170 | expect(() => modal(titleText, dummyBlocks, { 171 | notifyOnClose: 'abc', 172 | })).to.throw(VIEW_NOTIFY_ON_CLOSE_ERROR) 173 | }) 174 | 175 | it('should prevent hash non-string', () => { 176 | expect(() => modal(titleText, dummyBlocks, { 177 | hash: true, 178 | })).to.throw(VIEW_HASH_ERROR) 179 | }) 180 | it('should prevent externalId non-string', () => { 181 | expect(() => modal(titleText, dummyBlocks, { 182 | externalId: true, 183 | })).to.throw(VIEW_EXTERNAL_ID_ERROR) 184 | }) 185 | }) 186 | 187 | context('Home view', () => { 188 | it('should produce basic modal without options', () => { 189 | const expectedObject = { 190 | type: VIEW_HOME, 191 | blocks: dummyBlocks, 192 | } 193 | expect(home(dummyBlocks)).eql(expectedObject) 194 | }) 195 | 196 | it('should produce full home view with all options', () => { 197 | const expectedObject = { 198 | type: VIEW_HOME, 199 | blocks: dummyBlocks, 200 | private_metadata: JSON.stringify(privateMetadataObject), 201 | callback_id: callbackId, 202 | external_id: externalId, 203 | } 204 | 205 | const result = home( 206 | dummyBlocks, 207 | { 208 | privateMetadata: privateMetadataObject, 209 | callbackId, 210 | externalId, 211 | }, 212 | ) 213 | 214 | expect(result).eql(expectedObject) 215 | }) 216 | it('should prevent no blocks', () => { 217 | expect(() => home([])).to.throw(VIEW_NO_BLOCKS_ERROR) 218 | expect(() => home()).to.throw(VIEW_NO_BLOCKS_ERROR) 219 | }) 220 | it('should prevent no-string callbackId', () => { 221 | expect(() => home(dummyBlocks, { 222 | callbackId: true, 223 | })).to.throw(VIEW_CALLBACK_ID_ERROR) 224 | }) 225 | 226 | it('should prevent too long callbackId', () => { 227 | expect(() => home(dummyBlocks, { 228 | callbackId: 'aasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdasdsd', 229 | })).to.throw(VIEW_CALLBACK_ID_ERROR) 230 | }) 231 | 232 | it('should prevent externalId non-string', () => { 233 | expect(() => home(dummyBlocks, { 234 | externalId: true, 235 | })).to.throw(VIEW_EXTERNAL_ID_ERROR) 236 | }) 237 | 238 | it('should prevent more than 100 blocks case', () => { 239 | const tooManyBlocks = [] 240 | for (let i = 0; i <= 100; i += 1) { 241 | tooManyBlocks.push(divider()) 242 | } 243 | expect(() => home(tooManyBlocks)).to.throw(VIEW_TOO_MANY_BLOCKS_ERROR) 244 | }) 245 | 246 | 247 | it('should prevent too large metadata', () => { 248 | const tooManyBlocks = [] 249 | for (let i = 0; i <= 500; i += 1) { 250 | tooManyBlocks.push(divider()) 251 | } 252 | 253 | expect(() => home(dummyBlocks, { 254 | privateMetadata: tooManyBlocks, 255 | })).to.throw(VIEW_PMD_TOO_LONG_ERROR) 256 | }) 257 | }) 258 | 259 | context('Workflow step', () => { 260 | it('should produce basic workflow step without options', () => { 261 | const expectedObject = { 262 | type: VIEW_WORKFLOW_STEP, 263 | blocks: dummyBlocks, 264 | } 265 | 266 | expect(workflowStep(dummyBlocks)).eql(expectedObject) 267 | }) 268 | 269 | it('should produce full workflow step with all options', () => { 270 | const expectedObject = { 271 | type: VIEW_WORKFLOW_STEP, 272 | blocks: dummyBlocks, 273 | private_metadata: JSON.stringify(privateMetadataObject), 274 | callback_id: callbackId, 275 | } 276 | 277 | const result = workflowStep( 278 | dummyBlocks, 279 | { 280 | privateMetadata: privateMetadataObject, 281 | callbackId, 282 | }, 283 | ) 284 | 285 | expect(result).eql(expectedObject) 286 | }) 287 | 288 | it('should prevent more than 100 blocks case', () => { 289 | const tooManyBlocks = [] 290 | for (let i = 0; i <= 100; i += 1) { 291 | tooManyBlocks.push(divider()) 292 | } 293 | expect(() => workflowStep(tooManyBlocks)).to.throw(VIEW_TOO_MANY_BLOCKS_ERROR) 294 | }) 295 | 296 | it('should prevent no blocks case', () => { 297 | expect(() => workflowStep([])).to.throw(VIEW_NO_BLOCKS_ERROR) 298 | }) 299 | 300 | it('should prevent too long metadata', () => { 301 | const tooManyBlocks = [] 302 | for (let i = 0; i <= 500; i += 1) { 303 | tooManyBlocks.push(divider()) 304 | } 305 | 306 | expect(() => workflowStep(dummyBlocks, { privateMetadata: { tooManyBlocks }})).to.throw(VIEW_PMD_TOO_LONG_ERROR) 307 | }) 308 | 309 | it('should prevent no-string callbackId', () => { 310 | expect(() => workflowStep(dummyBlocks, { 311 | callbackId: true, 312 | })).to.throw(VIEW_CALLBACK_ID_ERROR) 313 | }) 314 | 315 | }) 316 | }) 317 | -------------------------------------------------------------------------------- /test/object-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | TEXT_FORMAT_PLAIN, 4 | TEXT_FORMAT_MRKDWN, 5 | CONVERSATION_TYPE_PUBLIC, 6 | CONVERSATION_TYPE_PRIVATE, 7 | CONVERSATION_TYPE_MPIM, 8 | text, 9 | option, 10 | optionGroup, 11 | optionGroups, 12 | confirm, 13 | filter, 14 | } from '../src/object' 15 | 16 | describe('Basic objects test suite', () => { 17 | context('text object', () => { 18 | const textValue = 'Too many *coffees*, not enough `meals`' 19 | 20 | it('should render basic text with defaults', () => { 21 | const expectedObject = { 22 | type: TEXT_FORMAT_PLAIN, 23 | text: textValue, 24 | } 25 | expect(text(textValue)).deep.eql(expectedObject) 26 | }) 27 | 28 | it('should render empty text with defaults', () => { 29 | const expectedObject = { 30 | type: TEXT_FORMAT_PLAIN, 31 | text: '', 32 | } 33 | expect(text('')).deep.eql(expectedObject) 34 | }) 35 | it('should render with mrkdwn formatting and no emoji', () => { 36 | const expectedObject = { 37 | type: TEXT_FORMAT_MRKDWN, 38 | text: textValue, 39 | } 40 | expect(text(textValue, TEXT_FORMAT_MRKDWN, { emoji: true })) 41 | .deep.eql(expectedObject) 42 | }) 43 | 44 | it('should render with plain_text, verbatim and emoji', () => { 45 | const expectedObject = { 46 | type: TEXT_FORMAT_PLAIN, 47 | text: textValue, 48 | emoji: true, 49 | verbatim: true, 50 | } 51 | expect(text( 52 | textValue, TEXT_FORMAT_PLAIN, 53 | { emoji: true, verbatim: true }, 54 | )).deep.eql(expectedObject) 55 | }) 56 | 57 | it('should render with plain_text, emoji false', () => { 58 | const expectedObject = { 59 | type: TEXT_FORMAT_PLAIN, 60 | text: textValue, 61 | emoji: false, 62 | } 63 | expect(text( 64 | textValue, TEXT_FORMAT_PLAIN, 65 | { emoji: false }, 66 | )).deep.eql(expectedObject) 67 | }) 68 | it('should throw error on undefined text', () => { 69 | expect(() => text(undefined)).to.throw('There is no `text` without textValue, even empty string is a value') 70 | }) 71 | it('should throw error on Unsupported formatting values', () => { 72 | expect(() => text(textValue, 'some-crazy-format')).to.throw('Unsupported formatting value: \'some-crazy-format\'') 73 | }) 74 | it('should throw error on Unsupported emoji value', () => { 75 | expect(() => text(textValue, TEXT_FORMAT_PLAIN, { emoji: 'string' })).to.throw('Emoji has to be boolean') 76 | }) 77 | it('should throw error on Unsupported verbatim value', () => { 78 | expect(() => text(textValue, TEXT_FORMAT_PLAIN, { verbatim: 'whatever-else' })).to.throw('Verbatim has to be boolean') 79 | }) 80 | }) 81 | 82 | context('option object', () => { 83 | const textValue = 'Maru' 84 | const value = 'maru' 85 | 86 | it('should return valid option object', () => { 87 | const expectedObject = { 88 | text: { 89 | type: TEXT_FORMAT_PLAIN, 90 | text: textValue, 91 | }, 92 | value, 93 | } 94 | 95 | expect(option(textValue, value)).deep.eql(expectedObject) 96 | }) 97 | 98 | it('should throw error for undefined value', () => { 99 | expect(() => option(textValue, undefined)).to.throw('Value has to be a string') 100 | }) 101 | 102 | it('should return option with descriptionText', () => { 103 | const descriptionText = 'Here is some description of the option above' 104 | const expectedObject = { 105 | text: { 106 | type: TEXT_FORMAT_PLAIN, 107 | text: textValue, 108 | }, 109 | value, 110 | description: { 111 | type: TEXT_FORMAT_PLAIN, 112 | text: descriptionText, 113 | }, 114 | } 115 | 116 | expect(option(textValue, value, { descriptionText })).deep.eql(expectedObject) 117 | }) 118 | 119 | it('should return option with url', () => { 120 | const url = 'https://www.workstreams.ai' 121 | const expectedObject = { 122 | text: { 123 | type: TEXT_FORMAT_PLAIN, 124 | text: textValue, 125 | }, 126 | value, 127 | url, 128 | } 129 | 130 | expect(option(textValue, value, { url })).deep.eql(expectedObject) 131 | }) 132 | 133 | it('should throw error when descriptionText is not a string', () => { 134 | const descriptionText = {} 135 | expect(() => option(textValue, value, { descriptionText })).to.throw('Option description text has to be string, max 75 characters') 136 | }) 137 | 138 | it('should throw error when descriptionText being too long', () => { 139 | const descriptionTextSample = 'Super long text over 3K characters' 140 | let descriptionText = '' 141 | for (let i = 0; i < 100; i += 1) { 142 | descriptionText += descriptionTextSample 143 | } 144 | expect(() => option(textValue, value, { descriptionText })).to.throw('Option description text has to be string, max 75 characters') 145 | }) 146 | }) 147 | 148 | context('option group object', () => { 149 | const labelText = 'Group A' 150 | const optionValue = 'value-3' 151 | const optionText = '*this is plaintext text*' 152 | 153 | it('should return expected structure for one option group', () => { 154 | const expectedObject = { 155 | label: { 156 | type: TEXT_FORMAT_PLAIN, 157 | text: labelText, 158 | }, 159 | options: [ 160 | { 161 | text: { 162 | type: TEXT_FORMAT_PLAIN, 163 | text: optionText, 164 | }, 165 | value: optionValue, 166 | }, 167 | ], 168 | } 169 | 170 | const resultObject = optionGroup( 171 | labelText, 172 | [option(optionText, optionValue)], 173 | ) 174 | 175 | expect(resultObject).deep.eql(expectedObject) 176 | }) 177 | it('should return expected structure with no options', () => { 178 | const expectedObject = { 179 | label: { 180 | type: TEXT_FORMAT_PLAIN, 181 | text: labelText, 182 | }, 183 | options: [], 184 | } 185 | expect(optionGroup(labelText)).deep.eql(expectedObject) 186 | }) 187 | it('should throw an error if the label is not a string', () => { 188 | expect(() => optionGroup(undefined, [])).to.throw('Label has to be a string') 189 | }) 190 | 191 | it('should throw an error if the options are not an array', () => { 192 | expect(() => optionGroup(labelText, {})).to.throw('Options have to be an array') 193 | }) 194 | 195 | it('should return 2 groups of options in option_groups object', () => { 196 | const label2Text = 'Group B' 197 | const option2Value = 'value-2' 198 | const option2Text = '*another* plain text' 199 | const expectedOptionGroups = [ 200 | optionGroup(labelText, [ 201 | option(optionText, optionValue), 202 | option(option2Text, option2Value), 203 | ]), 204 | optionGroup(label2Text, [ 205 | option(optionText, optionValue), 206 | option(option2Text, option2Value), 207 | ]), 208 | ] 209 | 210 | const expectedObject = { 211 | option_groups: expectedOptionGroups, 212 | } 213 | 214 | expect(optionGroups(expectedOptionGroups)).deep.eql(expectedObject) 215 | }) 216 | 217 | it('should throw an error if optionGroups are not an array', () => { 218 | expect(() => optionGroups({})).to.throw('Option groups have to be not-empty array') 219 | }) 220 | }) 221 | 222 | context('confirm', () => { 223 | const titleText = 'Are you sure you want to kill that dragon?' 224 | const textType = TEXT_FORMAT_MRKDWN 225 | const textValue = 'It won\'t be easy to drag that dragon from the bottom of the lake. *Are you sure you want to shoot it*?' 226 | const confirmText = 'Shoot' 227 | const denyText = 'Hmm better not' 228 | 229 | it('should create correct confirm object', () => { 230 | const expectedObject = { 231 | title: { 232 | type: TEXT_FORMAT_PLAIN, 233 | text: titleText, 234 | }, 235 | text: { 236 | type: TEXT_FORMAT_MRKDWN, 237 | text: textValue, 238 | }, 239 | confirm: { 240 | type: TEXT_FORMAT_PLAIN, 241 | text: confirmText, 242 | }, 243 | deny: { 244 | type: TEXT_FORMAT_PLAIN, 245 | text: denyText, 246 | }, 247 | } 248 | const result = confirm(titleText, textType, textValue, confirmText, denyText) 249 | expect(result).deep.eql(expectedObject) 250 | }) 251 | 252 | it('should throw errors on missing params', () => { 253 | expect(() => confirm(undefined)).to.throw('TitleText has to be a string') 254 | expect(() => confirm(titleText, undefined)).to.throw('TextType has to be a string') 255 | expect(() => confirm(titleText, TEXT_FORMAT_PLAIN, undefined)).to.throw('TextValue has to be a string') 256 | expect(() => confirm(titleText, TEXT_FORMAT_PLAIN, textValue, undefined)).to.throw('ConfirmText has to be a string') 257 | expect(() => confirm(titleText, TEXT_FORMAT_PLAIN, textValue, confirmText, undefined)).to.throw('DenyText has to be a string') 258 | }) 259 | }) 260 | context('filter', () => { 261 | it('should create a filter with all passed params', () => { 262 | const expectedValue = { 263 | include: [CONVERSATION_TYPE_PUBLIC, CONVERSATION_TYPE_PRIVATE], 264 | exclude_bot_users: true, 265 | exclude_external_shared_channels: true, 266 | } 267 | expect(filter([CONVERSATION_TYPE_PUBLIC, CONVERSATION_TYPE_PRIVATE], true, true)).deep.eql(expectedValue) 268 | }) 269 | it('should create a filter with all default values', () => { 270 | const expectedValue = { 271 | exclude_bot_users: false, 272 | exclude_external_shared_channels: false, 273 | } 274 | expect(filter()).deep.eql(expectedValue) 275 | }) 276 | 277 | it('should create a filter with default bool values', () => { 278 | const expectedValue = { 279 | include: [CONVERSATION_TYPE_MPIM], 280 | exclude_bot_users: false, 281 | exclude_external_shared_channels: false, 282 | } 283 | expect(filter([CONVERSATION_TYPE_MPIM])).deep.eql(expectedValue) 284 | }) 285 | 286 | 287 | it('should throw errors on invalid params', () => { 288 | expect(() => filter('something')).to.throw('Filter include has to be an array') 289 | expect(() => filter(['something'])).to.throw() 290 | expect(() => filter(undefined,'something')).to.throw('Filter excludeExternalSharedChannels has to be boolean') 291 | expect(() => filter(undefined, false, 'something')).to.throw('Filter excludeBotUsers has to be boolean') 292 | }) 293 | 294 | }) 295 | }) 296 | -------------------------------------------------------------------------------- /src/element.js: -------------------------------------------------------------------------------- 1 | import omit from 'lodash.omit' 2 | import isBoolean from 'lodash.isboolean' 3 | import isNumber from 'lodash.isnumber' 4 | import { isPresentString, typedWithoutUndefined } from './utils' 5 | import { text } from './object' 6 | 7 | 8 | // selects 9 | export const ELEMENT_STATIC_SELECT = 'static_select' 10 | export const ELEMENT_EXTERNAL_SELECT = 'external_select' 11 | export const ELEMENT_USERS_SELECT = 'users_select' 12 | export const ELEMENT_CONVERSATIONS_SELECT = 'conversations_select' 13 | export const ELEMENT_CHANNELS_SELECT = 'channels_select' 14 | 15 | // multi-selects 16 | export const ELEMENT_STATIC_MULTI_SELECT = 'multi_static_select' 17 | export const ELEMENT_EXTERNAL_MULTI_SELECT = 'multi_external_select' 18 | export const ELEMENT_USERS_MULTI_SELECT = 'multi_users_select' 19 | export const ELEMENT_CONVERSATIONS_MULTI_SELECT = 'multi_conversations_select' 20 | export const ELEMENT_CHANNELS_MULTI_SELECT = 'multi_channels_select' 21 | 22 | export const ELEMENT_OVERFLOW = 'overflow' 23 | export const ELEMENT_DATEPICKER = 'datepicker' 24 | export const ELEMENT_TIMEPICKER = 'timepicker' 25 | export const ELEMENT_PLAIN_TEXT_INPUT = 'plain_text_input' 26 | export const ELEMENT_IMAGE = 'image' 27 | export const ELEMENT_BUTTON = 'button' 28 | export const ELEMENT_RADIO_BUTTONS = 'radio_buttons' 29 | export const ELEMENT_CHECKBOXES = 'checkboxes' 30 | 31 | /** 32 | * BlockKit Element specific errors 33 | * 34 | * @returns {ElementError} 35 | */ 36 | export class ElementError extends Error { 37 | 38 | } 39 | 40 | const checkActionIdProp = (actionId) => { 41 | if (!isPresentString(actionId)) { 42 | throw new ElementError('ActionId is required') 43 | } 44 | return true 45 | } 46 | 47 | const checkOptionsArray = (options) => { 48 | if (!Array.isArray(options) || !options.length) { 49 | throw new ElementError('Options have to be not-empty array of options objects') 50 | } 51 | 52 | return true 53 | } 54 | 55 | const checkSelectRequiredProps = (actionId, placeholderText) => { 56 | checkActionIdProp(actionId) 57 | 58 | if (!isPresentString(placeholderText, 150)) { 59 | throw new ElementError('Placeholder text is required') 60 | } 61 | 62 | return true 63 | } 64 | 65 | const isValidImage = (imageUrl, altText) => { 66 | if (!isPresentString(imageUrl, 0)) { 67 | throw new ElementError('Image url is required as string') 68 | } 69 | 70 | if (!isPresentString(altText, 0)) { 71 | throw new ElementError('Alt text is required as string') 72 | } 73 | 74 | return true 75 | } 76 | const isValidButton = (actionId, textValue) => { 77 | checkActionIdProp(actionId) 78 | 79 | if (!isPresentString(textValue, 75)) { 80 | throw new ElementError('Button needs some text') 81 | } 82 | 83 | return true 84 | } 85 | const checkPlainInputText = ({ 86 | placeholderText, initialValue, multiLine, minLength, maxLength, 87 | }) => { 88 | if (placeholderText) { 89 | if (!isPresentString(placeholderText, 150)) { 90 | throw new ElementError('placeholderText has to be string with max length of 150 characters') 91 | } 92 | } 93 | 94 | if (initialValue) { 95 | if (!isPresentString(initialValue, 0)) { 96 | throw new ElementError('initialValue has to be a string') 97 | } 98 | } 99 | 100 | if (multiLine) { 101 | if (!isBoolean(multiLine)) { 102 | throw new ElementError('Multiline parameter has to be boolean') 103 | } 104 | } 105 | 106 | if (minLength) { 107 | if (!isNumber(minLength) || minLength > 3000) { 108 | throw new ElementError('minLength parameter has to be number lower than 3000') 109 | } 110 | } 111 | 112 | if (maxLength) { 113 | if (!isNumber(maxLength)) { 114 | throw new ElementError('maxLength parameter has to be a number') 115 | } 116 | } 117 | 118 | return true 119 | } 120 | 121 | const buildBasicStaticSelect = (actionId, placeholderText, options, { optionGroups }) => { 122 | if (!optionGroups) { 123 | checkOptionsArray(options) 124 | } 125 | 126 | let element = { 127 | action_id: actionId, 128 | placeholder: text(placeholderText), 129 | options, 130 | } 131 | 132 | if (optionGroups) { 133 | // T0D0 add check for optionGroups 134 | element = { 135 | ...omit(element, ['options']), 136 | option_groups: optionGroups, 137 | } 138 | } 139 | 140 | return element 141 | } 142 | 143 | /** 144 | * image element 145 | * 146 | * @param {string} imageUrl - required valid url for image 147 | * @Param {string} altText - required alt text for image 148 | * 149 | * @returns {object} 150 | */ 151 | const image = (imageUrl, altText) => 152 | isValidImage(imageUrl, altText) && typedWithoutUndefined(ELEMENT_IMAGE, { 153 | type: ELEMENT_IMAGE, 154 | image_url: imageUrl, 155 | alt_text: altText, 156 | }) 157 | 158 | 159 | /** 160 | * button element 161 | * 162 | * @param {string} actionId - required actionId 163 | * @param {string} textValue - required text for button 164 | * @param {object} options - { url, value, confirm, style } 165 | * 166 | * @returns {object} 167 | */ 168 | const button = (actionId, textValue, { 169 | url, value, confirm, style, 170 | } = {}) => 171 | isValidButton(actionId, textValue) && typedWithoutUndefined(ELEMENT_BUTTON, { 172 | action_id: actionId, 173 | text: text(textValue), 174 | url, 175 | value, 176 | confirm, 177 | style, 178 | }) 179 | 180 | /** 181 | * Radio buttons 182 | * 183 | * @param {string} actionId - required actionId 184 | * @param {array} options - required options 185 | * @param {object} opts - { initialOption, confirm } 186 | * 187 | * @returns {object} 188 | */ 189 | const radioButtons = ( 190 | actionId, options, 191 | { initialOption, confirm } = {}, 192 | ) => checkActionIdProp(actionId) 193 | && checkOptionsArray(options) 194 | && typedWithoutUndefined(ELEMENT_RADIO_BUTTONS, { 195 | action_id: actionId, 196 | options, 197 | initial_option: initialOption, 198 | confirm, 199 | }) 200 | 201 | /** 202 | * Checkboxes 203 | * 204 | * @param {string} actionId - required actionId 205 | * @param {array} options - required options 206 | * @param {object} opts - { initialOptions, confirm } 207 | * 208 | * @returns {object} 209 | */ 210 | 211 | const checkboxes = ( 212 | actionId, options, 213 | { initialOptions, confirm } = {}, 214 | ) => checkActionIdProp(actionId) 215 | && checkOptionsArray(options) 216 | && typedWithoutUndefined(ELEMENT_CHECKBOXES, { 217 | action_id: actionId, 218 | options, 219 | initial_options: initialOptions, 220 | confirm, 221 | }) 222 | 223 | /** 224 | * Static select menu 225 | * 226 | * @param {string} actionId - required actionId 227 | * @param {string} placeholderText - required placeholder text 228 | * @param {array} options - required options 229 | * @param {object} opts - { optionGroups, initialOption, confirm } 230 | * 231 | * @returns {object} 232 | */ 233 | const staticSelect = ( 234 | actionId, placeholderText, options, 235 | { optionGroups, initialOption, confirm } = {}, 236 | ) => checkSelectRequiredProps(actionId, placeholderText) 237 | && typedWithoutUndefined(ELEMENT_STATIC_SELECT, { 238 | ...buildBasicStaticSelect(actionId, placeholderText, options, { optionGroups }), 239 | initial_option: initialOption, 240 | confirm, 241 | }) 242 | 243 | /** 244 | * External select menu 245 | * 246 | * @param {string} actionId - required actionId 247 | * @param {string} placeholderText - required placeholder text 248 | * @param {object} opts - { initialOption, minQueryLength, confirm } 249 | * 250 | * @returns {object} 251 | */ 252 | const externalSelect = ( 253 | actionId, placeholderText, 254 | { initialOption, minQueryLength, confirm } = {}, 255 | ) => checkSelectRequiredProps(actionId, placeholderText) 256 | && typedWithoutUndefined(ELEMENT_EXTERNAL_SELECT, { 257 | action_id: actionId, 258 | placeholder: text(placeholderText), 259 | initial_option: initialOption, 260 | min_query_length: minQueryLength, 261 | confirm, 262 | }) 263 | 264 | /** 265 | * Users select menu 266 | * 267 | * @param {string} actionId - required actionId 268 | * @param {string} placeholderText - required placeholder text 269 | * @param {object} opts - { initialUser, confirm } 270 | * 271 | * @returns {object} 272 | */ 273 | const usersSelect = ( 274 | actionId, 275 | placeholderText, 276 | { initialUser, confirm } = {}, 277 | ) => 278 | checkSelectRequiredProps(actionId, placeholderText) 279 | && typedWithoutUndefined(ELEMENT_USERS_SELECT, { 280 | action_id: actionId, 281 | placeholder: text(placeholderText), 282 | initial_user: initialUser, 283 | confirm, 284 | }) 285 | 286 | /** 287 | * Conversations select menu 288 | * 289 | * @param {string} actionId - required actionId 290 | * @param {string} placeholderText - required placeholder text 291 | * @param {object} opts - { initialConversation, confirm, filter } 292 | * 293 | * @returns {object} 294 | */ 295 | const conversationsSelect = (actionId, placeholderText, { initialConversation, confirm, filter } = {}) => 296 | checkSelectRequiredProps(actionId, placeholderText) 297 | && typedWithoutUndefined(ELEMENT_CONVERSATIONS_SELECT, { 298 | action_id: actionId, 299 | placeholder: text(placeholderText), 300 | initial_conversation: initialConversation, 301 | confirm, 302 | filter 303 | }) 304 | 305 | /** 306 | * Channels select menu 307 | * 308 | * @param {string} actionId - required actionId 309 | * @param {string} placeholderText - required placeholder text 310 | * @param {object} opts - { initialChannel, confirm } 311 | * 312 | * @returns {object} 313 | */ 314 | const channelsSelect = (actionId, placeholderText, { initialChannel, confirm } = {}) => 315 | checkSelectRequiredProps(actionId, placeholderText) 316 | && typedWithoutUndefined(ELEMENT_CHANNELS_SELECT, { 317 | action_id: actionId, 318 | placeholder: text(placeholderText), 319 | initial_channel: initialChannel, 320 | confirm, 321 | }) 322 | 323 | /** 324 | * Overflow menu element 325 | * 326 | * @param {string} actionId - required actionId 327 | * @param {array} options - required array of option objects 328 | * @param {object} opts - { confirm } 329 | * 330 | * @returns {object} 331 | */ 332 | const overflow = (actionId, options, { confirm } = {}) => 333 | checkActionIdProp(actionId) 334 | && checkOptionsArray(options) 335 | && typedWithoutUndefined(ELEMENT_OVERFLOW, { 336 | action_id: actionId, 337 | options, 338 | confirm, 339 | }) 340 | 341 | /** 342 | * DatePicker element 343 | * 344 | * @param {string} actionId - required actionId 345 | * @param {object} opts - { placeholderText, initialDate, confirm } 346 | * 347 | * @returns {object} 348 | */ 349 | const datePicker = (actionId, { placeholderText, initialDate, confirm } = {}) => 350 | checkActionIdProp(actionId) 351 | && typedWithoutUndefined(ELEMENT_DATEPICKER, { 352 | action_id: actionId, 353 | placeholder: placeholderText ? text(placeholderText) : undefined, 354 | initial_date: initialDate, 355 | confirm, 356 | }) 357 | 358 | /** 359 | * TimePicker element 360 | * 361 | * @param {string} actionId - required actionId 362 | * @param {object} opts - { placeholderText, initialTime, confirm } 363 | * 364 | * @returns {object} 365 | */ 366 | const timePicker = (actionId, { placeholderText, initialTime, confirm } = {}) => 367 | checkActionIdProp(actionId) 368 | && typedWithoutUndefined(ELEMENT_TIMEPICKER, { 369 | action_id: actionId, 370 | placeholder: placeholderText ? text(placeholderText) : undefined, 371 | initial_time: initialTime, 372 | confirm, 373 | }) 374 | 375 | 376 | /** 377 | * PlainTextInput element 378 | * 379 | * @param {string} actionId - required actionId 380 | * @param {object} opts - { placeholderText, initialValue, multiLine, maxLength, minLength } 381 | * 382 | * @returns {object} 383 | */ 384 | const plainTextInput = (actionId, { 385 | placeholderText, initialValue, multiLine, minLength, maxLength, 386 | } = {}) => checkActionIdProp(actionId) 387 | && checkPlainInputText({ 388 | placeholderText, initialValue, multiLine, minLength, maxLength, 389 | }) 390 | && typedWithoutUndefined(ELEMENT_PLAIN_TEXT_INPUT, { 391 | action_id: actionId, 392 | placeholder: placeholderText ? text(placeholderText) : undefined, 393 | initial_value: initialValue, 394 | multiline: multiLine, 395 | min_length: minLength, 396 | max_length: maxLength, 397 | }) 398 | 399 | /** 400 | * MultiStaticSelect element 401 | * 402 | * @param {string} actionId - required actionId 403 | * @param {string} placeholderText - required placeholder text 404 | * @param [{option}] options - required options objects 405 | * @param {object} opts - { optionGroups, initialOptions, confirm, maxSelectedItems} 406 | * @returns {object} 407 | */ 408 | const multiStaticSelect = ( 409 | actionId, 410 | placeholderText, 411 | options, { 412 | optionGroups, 413 | initialOptions, 414 | confirm, 415 | maxSelectedItems, 416 | } = {}, 417 | ) => checkSelectRequiredProps(actionId, placeholderText) 418 | && typedWithoutUndefined(ELEMENT_STATIC_MULTI_SELECT, { 419 | ...buildBasicStaticSelect(actionId, placeholderText, options, { optionGroups }), 420 | initial_options: initialOptions, 421 | confirm, 422 | max_selected_items: maxSelectedItems, 423 | }) 424 | /** 425 | * MultiChannelsSelect element 426 | * 427 | * @param {string} actionId - required actionId 428 | * @param {string} placeholderText - required placeholder text 429 | * @param {object} opts - { initialChannels, confirm, maxSelectedItems} 430 | * @returns {object} 431 | */ 432 | const multiChannelsSelect = ( 433 | actionId, 434 | placeholderText, 435 | { initialChannels, confirm, maxSelectedItems } = {}, 436 | ) => 437 | checkSelectRequiredProps(actionId, placeholderText) 438 | && typedWithoutUndefined(ELEMENT_CHANNELS_MULTI_SELECT, { 439 | action_id: actionId, 440 | placeholder: text(placeholderText), 441 | initial_channels: initialChannels, 442 | confirm, 443 | max_selected_items: maxSelectedItems, 444 | }) 445 | 446 | /** 447 | * MultiConversationsSelect element 448 | * 449 | * @param {string} actionId - required actionId 450 | * @param {string} placeholderText - required placeholder text 451 | * @param {object} opts - { initialConversations, confirm, maxSelectedItems, filter } 452 | * @returns {object} 453 | */ 454 | const multiConversationsSelect = ( 455 | actionId, 456 | placeholderText, 457 | { initialConversations, maxSelectedItems, confirm, filter } = {}, 458 | ) => checkSelectRequiredProps(actionId, placeholderText) 459 | && typedWithoutUndefined(ELEMENT_CONVERSATIONS_MULTI_SELECT, { 460 | action_id: actionId, 461 | placeholder: text(placeholderText), 462 | initial_conversations: initialConversations, 463 | confirm, 464 | filter, 465 | max_selected_items: maxSelectedItems, 466 | }) 467 | 468 | /** 469 | * MultiExternalSelect element 470 | * 471 | * @param {string} actionId - required actionId 472 | * @param {string} placeholderText - required placeholder text 473 | * @param {object} opts - { initialOptions, confirm, maxSelectedItems, minQueryLength } 474 | * @returns {object} 475 | */ 476 | const multiExternalSelect = ( 477 | actionId, 478 | placeholderText, 479 | { 480 | minQueryLength, 481 | initialOptions, 482 | confirm, 483 | maxSelectedItems, 484 | } = {}, 485 | ) => checkSelectRequiredProps(actionId, placeholderText) 486 | && typedWithoutUndefined(ELEMENT_EXTERNAL_MULTI_SELECT, { 487 | action_id: actionId, 488 | placeholder: text(placeholderText), 489 | initial_options: initialOptions, 490 | confirm, 491 | min_query_length: minQueryLength, 492 | max_selected_items: maxSelectedItems, 493 | }) 494 | 495 | /** 496 | * MultiUsersSelect element 497 | * 498 | * @param {string} actionId - required actionId 499 | * @param {string} placeholderText - required placeholder text 500 | * @param {object} opts - { initialUsers, confirm, maxSelectedItems} 501 | * @returns {object} 502 | */ 503 | const multiUsersSelect = ( 504 | actionId, 505 | placeholderText, { 506 | initialUsers, 507 | confirm, 508 | maxSelectedItems, 509 | } = {}, 510 | ) => checkSelectRequiredProps(actionId, placeholderText) 511 | && typedWithoutUndefined(ELEMENT_USERS_MULTI_SELECT, { 512 | action_id: actionId, 513 | placeholder: text(placeholderText), 514 | initial_users: initialUsers, 515 | confirm, 516 | max_selected_items: maxSelectedItems, 517 | }) 518 | 519 | export { 520 | image, 521 | button, 522 | staticSelect, 523 | externalSelect, 524 | usersSelect, 525 | conversationsSelect, 526 | channelsSelect, 527 | overflow, 528 | datePicker, 529 | timePicker, 530 | plainTextInput, 531 | radioButtons, 532 | checkboxes, 533 | multiStaticSelect, 534 | multiChannelsSelect, 535 | multiExternalSelect, 536 | multiUsersSelect, 537 | multiConversationsSelect, 538 | } 539 | 540 | export default { 541 | image, 542 | button, 543 | staticSelect, 544 | externalSelect, 545 | usersSelect, 546 | conversationsSelect, 547 | channelsSelect, 548 | overflow, 549 | datePicker, 550 | timePicker, 551 | plainTextInput, 552 | radioButtons, 553 | checkboxes, 554 | multiStaticSelect, 555 | multiChannelsSelect, 556 | multiExternalSelect, 557 | multiUsersSelect, 558 | multiConversationsSelect, 559 | } 560 | -------------------------------------------------------------------------------- /test/element-test.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { 3 | image, button, radioButtons, checkboxes, 4 | staticSelect, usersSelect, channelsSelect, conversationsSelect, externalSelect, 5 | multiStaticSelect, multiUsersSelect, multiChannelsSelect, 6 | multiExternalSelect, multiConversationsSelect, 7 | overflow, datePicker, plainTextInput, timePicker, 8 | ELEMENT_IMAGE, ELEMENT_BUTTON, ELEMENT_RADIO_BUTTONS, ELEMENT_CHECKBOXES, 9 | ELEMENT_STATIC_SELECT, ELEMENT_USERS_SELECT, 10 | ELEMENT_CHANNELS_SELECT, ELEMENT_CONVERSATIONS_SELECT, ELEMENT_EXTERNAL_SELECT, 11 | ELEMENT_EXTERNAL_MULTI_SELECT, ELEMENT_USERS_MULTI_SELECT, 12 | ELEMENT_CHANNELS_MULTI_SELECT, ELEMENT_STATIC_MULTI_SELECT, 13 | ELEMENT_CONVERSATIONS_MULTI_SELECT, ELEMENT_TIMEPICKER, 14 | ELEMENT_OVERFLOW, ELEMENT_DATEPICKER, ELEMENT_PLAIN_TEXT_INPUT, 15 | } from '../src/element' 16 | 17 | import { 18 | text, 19 | confirm, 20 | option, 21 | optionGroup, 22 | optionGroups, 23 | filter, 24 | TEXT_FORMAT_PLAIN, 25 | CONVERSATION_TYPE_PRIVATE, 26 | CONVERSATION_TYPE_PUBLIC, 27 | } from '../src/object' 28 | 29 | describe('Elements test suit', () => { 30 | const actionId = 'my-action' 31 | const confirmObj = confirm( 32 | 'Are you sure', 33 | TEXT_FORMAT_PLAIN, 34 | 'This is one-way only action', 35 | 'Yes do it', 36 | 'Ahh no please', 37 | ) 38 | 39 | context('image', () => { 40 | const imageUrl = 'https://i.stack.imgur.com/hmazD.png' 41 | const altText = 'Funny lol-cat' 42 | 43 | it('should construct valid image element', () => { 44 | const expectedObject = { 45 | type: ELEMENT_IMAGE, 46 | image_url: imageUrl, 47 | alt_text: altText, 48 | } 49 | 50 | expect(image(imageUrl, altText)).deep.eql(expectedObject) 51 | }) 52 | 53 | it('should throw an error if imageUrl is missing', () => { 54 | const expectedErrorMessage = 'Image url is required as string' 55 | const expectedFailingCalls = [ 56 | () => image(undefined, altText), 57 | () => image(true, altText), 58 | () => image({}, altText), 59 | ] 60 | 61 | expectedFailingCalls.forEach(fn => expect(fn).to.throw(expectedErrorMessage)) 62 | }) 63 | 64 | it('should throw an error if altText is missing', () => { 65 | const expectedErrorMessage = 'Alt text is required as string' 66 | const expectedFailingCalls = [ 67 | () => image(imageUrl), 68 | () => image(imageUrl, true), 69 | () => image(imageUrl, {}), 70 | ] 71 | 72 | expectedFailingCalls.forEach(fn => expect(fn).to.throw(expectedErrorMessage)) 73 | }) 74 | }) 75 | 76 | context('Button element', () => { 77 | const textValue = 'Click me' 78 | const urlValue = 'https://google.com' 79 | const value = 'my-value' 80 | 81 | it('should return valid minimal button object', () => { 82 | const expectedObject = { 83 | type: ELEMENT_BUTTON, 84 | text: text(textValue), 85 | action_id: actionId, 86 | } 87 | expect(button(actionId, textValue)).deep.eql(expectedObject) 88 | }) 89 | 90 | it('should return valid full extended button object', () => { 91 | const expectedObject = { 92 | type: ELEMENT_BUTTON, 93 | text: text(textValue), 94 | action_id: actionId, 95 | url: urlValue, 96 | value, 97 | confirm: confirmObj, 98 | } 99 | 100 | expect( 101 | button(actionId, textValue, { url: urlValue, value, confirm: confirmObj }), 102 | ).deep.eql(expectedObject) 103 | }) 104 | 105 | it('should return valid partial extended button object', () => { 106 | const expectedObject = { 107 | type: ELEMENT_BUTTON, 108 | text: text(textValue), 109 | action_id: actionId, 110 | value, 111 | } 112 | 113 | expect( 114 | button(actionId, textValue, { value, confirm: undefined, url: undefined }), 115 | ).deep.eql(expectedObject) 116 | }) 117 | 118 | it('should throw error on missing actionId', () => { 119 | const expectedErrorMessage = 'ActionId is required' 120 | const expectedFailingCalls = [ 121 | () => button(undefined, textValue), 122 | () => button(true, textValue), 123 | () => button({}, textValue), 124 | ] 125 | 126 | expectedFailingCalls.forEach(fn => expect(fn).to.throw(expectedErrorMessage)) 127 | }) 128 | 129 | it('should throw error on missing text Value', () => { 130 | const expectedErrorMessage = 'Button needs some text' 131 | const expectedFailingCalls = [ 132 | () => button(actionId), 133 | () => button(actionId, true), 134 | () => button(actionId, {}), 135 | ] 136 | 137 | expectedFailingCalls.forEach(fn => expect(fn).to.throw(expectedErrorMessage)) 138 | }) 139 | }) 140 | context('Select menus', () => { 141 | const placeholderText = 'Choose option' 142 | const initialUser = 'U1234' 143 | const initialConversation = 'C1234' 144 | const initialChannel = 'C1234' 145 | 146 | const options = [ 147 | option('option 1', 'option-1'), 148 | option('option 2', 'option-2'), 149 | ] 150 | const optionGroupsObj = optionGroups([ 151 | optionGroup('Group 1', options), 152 | optionGroup('Group 2', options), 153 | ]) 154 | const initialOption = options[0] 155 | const selectBaseObject = { 156 | action_id: actionId, 157 | placeholder: text(placeholderText), 158 | } 159 | context('Static select element', () => { 160 | const basicExpectedObj = { 161 | ...selectBaseObject, 162 | type: ELEMENT_STATIC_SELECT, 163 | } 164 | 165 | it('should return basic static select element with options', () => { 166 | const expectedObject = { 167 | ...basicExpectedObj, 168 | options, 169 | } 170 | expect(staticSelect(actionId, placeholderText, options)).deep.eql(expectedObject) 171 | }) 172 | 173 | it('should return optionGroups instead of options', () => { 174 | const expectedObject = { 175 | ...basicExpectedObj, 176 | ...optionGroupsObj, 177 | } 178 | expect(staticSelect( 179 | actionId, placeholderText, undefined, { optionGroups: optionGroupsObj.option_groups }, 180 | )).eql(expectedObject) 181 | expect(staticSelect( 182 | actionId, placeholderText, options, { optionGroups: optionGroupsObj.option_groups }, 183 | )).eql(expectedObject) 184 | }) 185 | 186 | it('should return obj with initial value and confirm', () => { 187 | const expectedObject = { 188 | ...basicExpectedObj, 189 | options, 190 | initial_option: initialOption, 191 | confirm: confirmObj, 192 | } 193 | expect( 194 | staticSelect(actionId, placeholderText, options, { initialOption, confirm: confirmObj }), 195 | ).deep.eql(expectedObject) 196 | }) 197 | 198 | it('should trow an error with options are missing', () => { 199 | expect(() => staticSelect(actionId, placeholderText)).to.throw('Options have to be not-empty array of options objects') 200 | }) 201 | 202 | it('should throw an error on missing actionId', () => { 203 | expect(() => staticSelect(undefined, placeholderText, options)).to.throw('ActionId is required') 204 | }) 205 | it('should throw an error on missing placeholderText', () => { 206 | expect(() => staticSelect(actionId, null, options)).to.throw('Placeholder text is required') 207 | }) 208 | }) 209 | 210 | context('external select element', () => { 211 | const basicExpectedObj = { 212 | ...selectBaseObject, 213 | type: ELEMENT_EXTERNAL_SELECT, 214 | } 215 | const minQueryLength = 0 216 | 217 | it('should return minimal external select object', () => { 218 | const expectedObject = { 219 | ...basicExpectedObj, 220 | } 221 | expect(externalSelect(actionId, placeholderText)).deep.eql(expectedObject) 222 | }) 223 | 224 | it('should return external select with all opts', () => { 225 | const expectedObject = { 226 | ...basicExpectedObj, 227 | initial_option: initialOption, 228 | min_query_length: minQueryLength, 229 | confirm: confirmObj, 230 | } 231 | 232 | expect(externalSelect(actionId, placeholderText, { 233 | initialOption, minQueryLength, confirm: confirmObj, 234 | })).deep.eql(expectedObject) 235 | }) 236 | 237 | it('should throw an error on missing actionId', () => { 238 | expect(() => externalSelect(undefined, placeholderText)).to.throw('ActionId is required') 239 | }) 240 | it('should throw an error on missing placeholderText', () => { 241 | expect(() => externalSelect(actionId, null)).to.throw('Placeholder text is required') 242 | }) 243 | }) 244 | 245 | context('Users select element', () => { 246 | const basicExpectedObj = { 247 | ...selectBaseObject, 248 | type: ELEMENT_USERS_SELECT, 249 | } 250 | it('should return basic users select', () => { 251 | expect(usersSelect(actionId, placeholderText)).deep.eql(basicExpectedObj) 252 | }) 253 | 254 | it('should return users select with all opts', () => { 255 | const expectedObject = { 256 | ...basicExpectedObj, 257 | confirm: confirmObj, 258 | initial_user: initialUser, 259 | } 260 | 261 | expect(usersSelect(actionId, placeholderText, { confirm: confirmObj, initialUser })) 262 | .deep.eql(expectedObject) 263 | }) 264 | }) 265 | 266 | context('Conversations select', () => { 267 | const basicExpectedObj = { 268 | ...selectBaseObject, 269 | type: ELEMENT_CONVERSATIONS_SELECT, 270 | } 271 | it('should return basic conversations select', () => { 272 | expect(conversationsSelect(actionId, placeholderText)).deep.eql(basicExpectedObj) 273 | }) 274 | 275 | it('should return conversations select with all opts', () => { 276 | const expectedObject = { 277 | ...basicExpectedObj, 278 | confirm: confirmObj, 279 | initial_conversation: initialConversation, 280 | } 281 | 282 | expect( 283 | conversationsSelect( 284 | actionId, placeholderText, 285 | { confirm: confirmObj, initialConversation }, 286 | ), 287 | ).deep.eql(expectedObject) 288 | }) 289 | }) 290 | 291 | context('Channels select', () => { 292 | const basicExpectedObj = { 293 | ...selectBaseObject, 294 | type: ELEMENT_CHANNELS_SELECT, 295 | } 296 | it('should return basic channels select', () => { 297 | expect(channelsSelect(actionId, placeholderText)).deep.eql(basicExpectedObj) 298 | }) 299 | 300 | it('should return channels select with all opts', () => { 301 | const expectedObject = { 302 | ...basicExpectedObj, 303 | confirm: confirmObj, 304 | initial_channel: initialChannel, 305 | } 306 | 307 | expect(channelsSelect(actionId, placeholderText, { confirm: confirmObj, initialChannel })) 308 | .deep.eql(expectedObject) 309 | }) 310 | }) 311 | context('overflow menu', () => { 312 | it('should return basic overflow menu', () => { 313 | expect(overflow(actionId, options)).deep.eql({ 314 | type: ELEMENT_OVERFLOW, 315 | action_id: actionId, 316 | options, 317 | }) 318 | }) 319 | it('should return overflow menu with confirm', () => { 320 | expect(overflow(actionId, options, { confirm: confirmObj })).deep.eql({ 321 | type: ELEMENT_OVERFLOW, 322 | action_id: actionId, 323 | options, 324 | confirm: confirmObj, 325 | }) 326 | }) 327 | it('should throw error on missing actionId', () => { 328 | expect(() => overflow(undefined, options)).to.throw('ActionId is required') 329 | }) 330 | it('should throw error on missing options', () => { 331 | expect(() => overflow(actionId)).to.throw('Options have to be not-empty array of options objects') 332 | expect(() => overflow(actionId, {})).to.throw('Options have to be not-empty array of options objects') 333 | expect(() => overflow(actionId, [])).to.throw('Options have to be not-empty array of options objects') 334 | }) 335 | }) 336 | context('multi selects with full options', () => { 337 | const maxSelectedItems = 23 338 | const initialOptions = [options[1], options[0]] 339 | const multiSelectBaseObject = { 340 | ...selectBaseObject, 341 | confirm: confirmObj, 342 | max_selected_items: maxSelectedItems, 343 | } 344 | it('should return static multi select menu', () => { 345 | const expectedObject = { 346 | ...multiSelectBaseObject, 347 | type: ELEMENT_STATIC_MULTI_SELECT, 348 | options, 349 | initial_options: initialOptions, 350 | } 351 | const result = multiStaticSelect( 352 | actionId, 353 | placeholderText, 354 | options, 355 | { confirm: confirmObj, maxSelectedItems, initialOptions }, 356 | ) 357 | 358 | expect(result).eql(expectedObject) 359 | }) 360 | 361 | it('should return external multi select menu', () => { 362 | const expectedObject = { 363 | ...multiSelectBaseObject, 364 | min_query_length: 0, 365 | type: ELEMENT_EXTERNAL_MULTI_SELECT, 366 | initial_options: initialOptions, 367 | } 368 | const result = multiExternalSelect( 369 | actionId, 370 | placeholderText, 371 | { 372 | confirm: confirmObj, 373 | maxSelectedItems, 374 | initialOptions, 375 | minQueryLength: 0, 376 | }, 377 | ) 378 | expect(result).eql(expectedObject) 379 | }) 380 | 381 | it('should return users multi select menu', () => { 382 | const expectedObject = { 383 | ...multiSelectBaseObject, 384 | type: ELEMENT_USERS_MULTI_SELECT, 385 | initial_users: [initialUser], 386 | } 387 | const result = multiUsersSelect( 388 | actionId, 389 | placeholderText, 390 | { 391 | confirm: confirmObj, 392 | maxSelectedItems, 393 | initialUsers: [initialUser], 394 | }, 395 | ) 396 | expect(result).eql(expectedObject) 397 | }) 398 | 399 | it('should return conversations multi select menu', () => { 400 | const expectedObject = { 401 | ...multiSelectBaseObject, 402 | type: ELEMENT_CONVERSATIONS_MULTI_SELECT, 403 | initial_conversations: [initialConversation], 404 | filter: { 405 | include: [CONVERSATION_TYPE_PUBLIC, CONVERSATION_TYPE_PRIVATE], 406 | exclude_external_shared_channels: false, 407 | exclude_bot_users: false, 408 | } 409 | } 410 | const result = multiConversationsSelect( 411 | actionId, 412 | placeholderText, 413 | { 414 | confirm: confirmObj, 415 | maxSelectedItems, 416 | initialConversations: [initialConversation], 417 | filter: filter([CONVERSATION_TYPE_PUBLIC, CONVERSATION_TYPE_PRIVATE]) 418 | }, 419 | ) 420 | expect(result).eql(expectedObject) 421 | }) 422 | 423 | it('should return channels multi select menu', () => { 424 | const expectedObject = { 425 | ...multiSelectBaseObject, 426 | type: ELEMENT_CHANNELS_MULTI_SELECT, 427 | initial_channels: [initialChannel], 428 | } 429 | const result = multiChannelsSelect( 430 | actionId, 431 | placeholderText, 432 | { 433 | confirm: confirmObj, 434 | maxSelectedItems, 435 | initialChannels: [initialChannel], 436 | }, 437 | ) 438 | expect(result).eql(expectedObject) 439 | }) 440 | }) 441 | 442 | context('Default multi selects - no options', () => { 443 | const expectedTypes = { 444 | [ELEMENT_EXTERNAL_MULTI_SELECT]: multiExternalSelect, 445 | [ELEMENT_USERS_MULTI_SELECT]: multiUsersSelect, 446 | [ELEMENT_CHANNELS_MULTI_SELECT]: multiChannelsSelect, 447 | [ELEMENT_CONVERSATIONS_MULTI_SELECT]: multiConversationsSelect, 448 | } 449 | Object.keys(expectedTypes) 450 | .forEach(type => it(`should return default ${type}`, () => { 451 | const expectedObject = { 452 | type, 453 | ...selectBaseObject, 454 | } 455 | expect(expectedTypes[type](actionId, placeholderText)).eql(expectedObject) 456 | })) 457 | 458 | it('static select with options', () => { 459 | const expectedObject = { 460 | type: ELEMENT_STATIC_MULTI_SELECT, 461 | ...selectBaseObject, 462 | options, 463 | } 464 | expect(multiStaticSelect(actionId, placeholderText, options)).eql(expectedObject) 465 | }) 466 | }) 467 | }) 468 | context('Date elements', () => { 469 | context('date picker element', () => { 470 | const placeholderText = 'Pick a date' 471 | 472 | it('should return basic datePicker', () => { 473 | const expectedObject = { 474 | type: ELEMENT_DATEPICKER, 475 | action_id: actionId, 476 | } 477 | expect(datePicker(actionId)).eql(expectedObject) 478 | }) 479 | 480 | it('should return advanced datePicker', () => { 481 | const initialDate = '2019-01-18' 482 | const expectedObject = { 483 | type: ELEMENT_DATEPICKER, 484 | action_id: actionId, 485 | placeholder: text(placeholderText), 486 | initial_date: initialDate, 487 | confirm: confirmObj, 488 | } 489 | 490 | expect( 491 | datePicker(actionId, { placeholderText, initialDate, confirm: confirmObj }), 492 | ).deep.eql(expectedObject) 493 | }) 494 | it('should throw error on missing actionId', () => { 495 | expect(() => datePicker(undefined)).to.throw('ActionId is required') 496 | }) 497 | }) 498 | context('time picker element', () => { 499 | const placeholderText = 'Pick a time' 500 | 501 | it('should return basic timePicker', () => { 502 | const expectedObject = { 503 | type: ELEMENT_TIMEPICKER, 504 | action_id: actionId, 505 | } 506 | expect(timePicker(actionId)).eql(expectedObject) 507 | }) 508 | 509 | it('should return advanced timePicker', () => { 510 | const initialTime = '23:23' 511 | const expectedObject = { 512 | type: ELEMENT_TIMEPICKER, 513 | action_id: actionId, 514 | placeholder: text(placeholderText), 515 | initial_time: initialTime, 516 | confirm: confirmObj, 517 | } 518 | 519 | expect( 520 | timePicker(actionId, { placeholderText, initialTime, confirm: confirmObj }), 521 | ).deep.eql(expectedObject) 522 | }) 523 | it('should throw error on missing actionId', () => { 524 | expect(() => timePicker(undefined)).to.throw('ActionId is required') 525 | }) 526 | }) 527 | 528 | }) 529 | 530 | context('Radio buttons', () => { 531 | const option1 = option('option 1', 'option-1') 532 | const option2 = option('option 2', 'option-2') 533 | 534 | const options = [ 535 | option1, option2, 536 | ] 537 | 538 | it('should return basic radio buttons', () => { 539 | const expectedObject = { 540 | type: ELEMENT_RADIO_BUTTONS, 541 | action_id: actionId, 542 | options: [...options], 543 | } 544 | const rButtons = radioButtons(actionId, options) 545 | expect(rButtons).deep.eql(expectedObject) 546 | }) 547 | 548 | it('should return fully configured radio buttons', () => { 549 | const expectedObject = { 550 | type: ELEMENT_RADIO_BUTTONS, 551 | action_id: actionId, 552 | initial_option: option1, 553 | options: [...options], 554 | confirm: confirmObj, 555 | } 556 | const rButtons = radioButtons( 557 | actionId, options, { 558 | initialOption: option1, confirm: confirmObj, 559 | }, 560 | ) 561 | expect(rButtons).deep.eql(expectedObject) 562 | }) 563 | 564 | it('should throw errors', () => { 565 | expect(() => radioButtons()).to.throw('ActionId is required') 566 | expect(() => radioButtons(actionId)).to.throw('Options have to be not-empty array of options objects') 567 | }) 568 | }) 569 | 570 | context('Checkboxes', () => { 571 | const option1 = option('option 1', 'option-1') 572 | const option2 = option('option 2', 'option-2') 573 | const option3 = option('option 3', 'option-3') 574 | 575 | const options = [ 576 | option1, option2, option3, 577 | ] 578 | 579 | it('should return basic checkboxes', () => { 580 | const expectedObject = { 581 | type: ELEMENT_CHECKBOXES, 582 | action_id: actionId, 583 | options: [...options], 584 | } 585 | expect( 586 | checkboxes(actionId, options), 587 | ).deep.eql(expectedObject) 588 | }) 589 | 590 | it('should return fully configured checkboxes', () => { 591 | const expectedObject = { 592 | type: ELEMENT_CHECKBOXES, 593 | action_id: actionId, 594 | initial_options: [option1, option2], 595 | options: [...options], 596 | confirm: confirmObj, 597 | } 598 | 599 | expect( 600 | checkboxes(actionId, options, { initialOptions: [option1, option2], confirm: confirmObj }), 601 | ).deep.eql(expectedObject) 602 | }) 603 | 604 | it('should throw errors', () => { 605 | expect(() => checkboxes()).to.throw('ActionId is required') 606 | expect(() => checkboxes(actionId)).to.throw('Options have to be not-empty array of options objects') 607 | }) 608 | }) 609 | 610 | context('input elements', () => { 611 | const validActionId = 'myActionId' 612 | const invalidActionId = true 613 | 614 | it('should return basic plain text input', () => { 615 | const textInput = plainTextInput(validActionId) 616 | const expectedObject = { 617 | type: ELEMENT_PLAIN_TEXT_INPUT, 618 | action_id: validActionId, 619 | } 620 | 621 | expect(textInput).eql(expectedObject) 622 | }) 623 | 624 | it('should return full plain text input', () => { 625 | const inputValues = { 626 | placeholderText: 'your favourite serie', 627 | initialValue: 'Expanse', 628 | multiLine: true, 629 | minLength: 3, 630 | maxLength: 100, 631 | } 632 | 633 | const textInput = plainTextInput(validActionId, inputValues) 634 | expect(textInput).eql({ 635 | type: ELEMENT_PLAIN_TEXT_INPUT, 636 | action_id: validActionId, 637 | initial_value: inputValues.initialValue, 638 | placeholder: text(inputValues.placeholderText), 639 | multiline: inputValues.multiLine, 640 | min_length: inputValues.minLength, 641 | max_length: inputValues.maxLength, 642 | }) 643 | }) 644 | 645 | it('should trhow an error on missing actionId', () => { 646 | expect(() => plainTextInput()).to.throw('ActionId is required') 647 | expect(() => plainTextInput(invalidActionId)).to.throw('ActionId is required') 648 | }) 649 | 650 | it('should throw an error on wrong placeholder', () => { 651 | expect(() => plainTextInput(validActionId, { placeholderText: true })).to.throw() 652 | }) 653 | 654 | it('should throw an error on wrong initialValuer', () => { 655 | expect(() => plainTextInput(validActionId, { initialValue: true })).to.throw() 656 | }) 657 | 658 | it('should throw an error on too long minLength', () => { 659 | expect(() => plainTextInput(validActionId, { minLength: 123123 })).to.throw('minLength parameter has to be number lower than 3000') 660 | }) 661 | 662 | it('should throw an error on too long minLength', () => { 663 | expect(() => plainTextInput(validActionId, { minLength: true })).to.throw('minLength parameter has to be number lower than 3000') 664 | }) 665 | 666 | it('should throw an error on too long maxLength', () => { 667 | expect(() => plainTextInput(validActionId, { maxLength: 'some value' })).to.throw('maxLength parameter has to be a number') 668 | }) 669 | 670 | it('should throw an error on wrong multiLine', () => { 671 | expect(() => plainTextInput(validActionId, { multiLine: 'string value' })).to.throw('Multiline parameter has to be boolean') 672 | }) 673 | }) 674 | }) 675 | --------------------------------------------------------------------------------