├── .env.example ├── .eslintrc ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── docs ├── documentation.md └── getting-started.md ├── jest.config.js ├── package.json ├── prod.tsconfig.json ├── screenshots ├── copy-member-id.png ├── doggies.gif ├── hero.gif ├── home-app-webhook-setup.png ├── home-app.png ├── interactive-webhook-setup.png ├── message.png ├── modal.png ├── screenshot1.png └── view profile.png ├── src ├── @types │ └── jsx.d.ts ├── core │ ├── components.tsx │ ├── index.ts │ ├── interfaces.ts │ ├── phelia.ts │ ├── reconciler.ts │ └── utils.ts ├── example │ ├── example-messages │ │ ├── birthday-picker.tsx │ │ ├── channels-select-menu.tsx │ │ ├── conversations-select-menu.tsx │ │ ├── counter.tsx │ │ ├── external-select-menu.tsx │ │ ├── greeter.tsx │ │ ├── home-app.tsx │ │ ├── index.ts │ │ ├── modal-example.tsx │ │ ├── multi-channels-select-menu.tsx │ │ ├── multi-conversations-select-menu.tsx │ │ ├── multi-external-select-menu.tsx │ │ ├── multi-static-select-menu.tsx │ │ ├── multi-users-select-menu.tsx │ │ ├── overflow-menu.tsx │ │ ├── radio-buttons.tsx │ │ ├── random-image.tsx │ │ ├── static-select-menu.tsx │ │ └── users-select-menu.tsx │ └── server.ts ├── index.ts └── tests │ ├── __snapshots__ │ └── reconciler.spec.tsx.snap │ └── reconciler.spec.tsx ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | SLACK_TOKEN= 2 | SLACK_SIGNING_SECRET= 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "jsdoc"], 4 | "parserOptions": { 5 | "sourceType": "module" 6 | }, 7 | "rules": { 8 | "jsdoc/check-param-names": 1, 9 | "jsdoc/check-tag-names": 1, 10 | "jsdoc/newline-after-description": 1, 11 | "jsdoc/no-types": 1, 12 | "jsdoc/require-param-description": 1, 13 | "jsdoc/require-returns-description": 1, 14 | "jsdoc/require-hyphen-before-param-description": [1, "always"], 15 | "jsdoc/require-jsdoc": [ 16 | 2, 17 | { 18 | "contexts": ["TSPropertySignature"], 19 | "require": { 20 | "ArrowFunctionExpression": true, 21 | "FunctionDeclaration": true, 22 | "ClassDeclaration": true 23 | } 24 | } 25 | ] 26 | }, 27 | "overrides": [ 28 | { 29 | "files": ["*.spec.*", "**/@types/**","**/example/**"], 30 | "rules": { 31 | "jsdoc/require-jsdoc": 0 32 | } 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: '10.x' 13 | 14 | - name: Cache node modules 15 | uses: actions/cache@preview 16 | with: 17 | path: node_modules 18 | key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} 19 | restore-keys: | 20 | ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} 21 | 22 | - name: Install Dependencies 23 | run: yarn install 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | yarn-error.log 3 | node_modules 4 | dist 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | screenshots 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Max Chehab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # ⚡ Phelia 6 | 7 | > React for Slack Apps 8 | 9 | Build interactive Slack apps without webhooks or JSON headache. If you know React, you know how to make a Slack app. 10 | 11 | # Quick start 12 | 13 | 1. Create your message with React: 14 | 15 | ```tsx 16 | import randomImage from "../utils"; 17 | 18 | export function RandomImage({ useState }: PheliaMessageProps) { 19 | const [imageUrl, setImageUrl] = useState("imageUrl", randomImage()); 20 | 21 | return ( 22 | 23 | 29 | 30 | 31 | 38 | 39 | 40 | ); 41 | } 42 | ``` 43 | 44 | 2. Register your component 45 | 46 | ```ts 47 | const client = new Phelia(process.env.SLACK_TOKEN); 48 | 49 | app.post( 50 | "/interactions", 51 | client.messageHandler(process.env.SLACK_SIGNING_SECRET, [RandomImage]) 52 | ); 53 | 54 | client.postMessage(RandomImage, "@max"); 55 | ``` 56 | 57 | 3. Interact with your message: 58 |

59 | 60 |

61 | 62 | See: [docs](https://github.com/maxchehab/phelia/blob/master/docs) for more info or join our [community Slack](https://join.slack.com/t/phelia/shared_invite/zt-dm4ln2w5-6aOXvv5ewiifDJGsplcVjA). 63 | 64 | # How this works 65 | 66 | Phelia transforms React components into Slack messages by use of a custom [React reconciler](https://github.com/maxchehab/phelia/blob/master/src/core/reconciler.ts). Components (with their internal state and props) are serialized into a [custom storage](https://github.com/maxchehab/phelia/wiki/Documentation#custom-storage). When a user interacts with a posted message Phelia retrieves the component, re-hydrates it's state and props, and performs any actions which may result in a new state. 67 | 68 | ## Components 69 | 70 | Each component is a mapping of a specific object type for a slack block. 71 | There are 3 categories of components, each with special rules for how that component can be used with other components. 72 | 73 | 1. Surface Components (Message, Home, Modal) - Root components that contains Block Components 74 | 2. Block Components (Actions, Context, Divider, Image, Input, Section) - Direct descendants of a Surface Component. Contains Block Components 75 | 3. Block Element Components (Text, CheckBoxes, TextField, etc) - Direct descendants of a Block Components. 76 | 77 | # Feature Support 78 | 79 | To request a feature [submit a new issue](https://github.com/maxchehab/phelia/issues/new). 80 | | Component | | Example | 81 | | ---------------------------------------------------------------------------------------------------------------------- | --- | ---------------------------------------------------------------------------------------------------------------------------------- | 82 | | [Actions](https://api.slack.com/reference/block-kit/blocks#actions) | ✅ | [Counter](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/counter.tsx) | 83 | | [Button](https://api.slack.com/reference/block-kit/block-elements#button) | ✅ | [Counter](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/counter.tsx) | 84 | | [Channel Select Menus](https://api.slack.com/reference/block-kit/block-elements#channels_select) | ✅ | [Channel Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/channels-select-menu.tsx) | 85 | | [Checkboxes](https://api.slack.com/reference/block-kit/block-elements#checkboxes) | ✅ | [Modal Example](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/modal-example.tsx) | 86 | | [Confirmation dialog](https://api.slack.com/reference/block-kit/composition-objects#confirm) | ✅ | [Random Image](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/random-image.tsx) | 87 | | [Context](https://api.slack.com/reference/block-kit/blocks#context) | ✅ | 88 | | [Conversation Select Menus](https://api.slack.com/reference/block-kit/block-elements#conversations_select) | ✅ | [Conversation Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/conversations-select-menu.tsx) | 89 | | [Date Picker](https://api.slack.com/reference/block-kit/block-elements#datepicker) | ✅ | [Birthday Picker](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/birthday-picker.tsx) | 90 | | [Divider](https://api.slack.com/reference/block-kit/blocks#divider) | ✅ | [Random Image](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/random-image.tsx) | 91 | | [External Select Menus](https://api.slack.com/reference/block-kit/block-elements#external_select) | ✅ | [External Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/external-select-menu.tsx) | 92 | | [Home Tab](https://api.slack.com/surfaces/tabs) | ✅ | [Home App Example](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/home-app.tsx) | 93 | | [Image Block](https://api.slack.com/reference/block-kit/blocks#image) | ✅ | [Random Image](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/random-image.tsx) | 94 | | [Image](https://api.slack.com/reference/block-kit/block-elements#image) | ✅ | [Random Image](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/random-image.tsx) | 95 | | [Input](https://api.slack.com/reference/block-kit/blocks#input) | ✅ | [Modal Example](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/modal-example.tsx) | 96 | | [Messages](https://api.slack.com/surfaces/messages) | ✅ | [Server](https://github.com/maxchehab/phelia/blob/master/src/example/server.ts) | 97 | | [Modals](https://api.slack.com/surfaces/modals) | ✅ | [Modal Example](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/modal-example.tsx) | 98 | | [Multi channels select Menu](https://api.slack.com/reference/block-kit/block-elements#multi_channels_select) | ✅ | [Multi Channels Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/multi-channels-select-menu.tsx) | 99 | | [Multi conversations select Menu](https://api.slack.com/reference/block-kit/block-elements#multi_conversations_select) | ✅ | [Multi Conversations Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/multi-conversations-select-menu.tsx) | 100 | | [Multi external select Menu](https://api.slack.com/reference/block-kit/block-elements#multi_external_select) | ✅ | [Multi External Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/multi-external-select-menu.tsx) | 101 | | [Multi static select Menu](https://api.slack.com/reference/block-kit/block-elements#multi_select) | ✅ | [Multi Static Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/multi-static-select-menu.tsx) | 102 | | [Multi users select Menu](https://api.slack.com/reference/block-kit/block-elements#multi_users_select) | ✅ | [Multi Users Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/multi-users-select-menu.tsx) | 103 | | [Option group](https://api.slack.com/reference/block-kit/composition-objects#option_group) | ✅ | [Static Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/static-select-menu.tsx) | 104 | | [Option](https://api.slack.com/reference/block-kit/composition-objects#option) | ✅ | 105 | | [Overflow Menu](https://api.slack.com/reference/block-kit/block-elements#overflow) | ✅ | [Overflow Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/overflow-menu.tsx) | 106 | | [Plain-text input](https://api.slack.com/reference/block-kit/block-elements#input) | ✅ | [Modal Example](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/modal-example.tsx) | 107 | | [Radio button group](https://api.slack.com/reference/block-kit/block-elements#radio) | ✅ | [Radio Buttons](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/radio-buttons.tsx) | 108 | | [Section](https://api.slack.com/reference/block-kit/blocks#section) | ✅ | [Counter](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/counter.tsx) | 109 | | [Static Select Menus](https://api.slack.com/reference/block-kit/block-elements#static_select) | ✅ | [Static Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/static-select-menu.tsx) | 110 | | [Text](https://api.slack.com/reference/block-kit/composition-objects#text) | ✅ | [Counter](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/counter.tsx) | 111 | | [Text](https://api.slack.com/reference/block-kit/composition-objects#text) | ✅ | [Random Image](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/random-image.tsx) | 112 | | [User Select Menus](https://api.slack.com/reference/block-kit/block-elements#users_select) | ✅ | [User Select Menu](https://github.com/maxchehab/phelia/blob/master/src/example/example-messages/users-select-menu.tsx) | 113 | -------------------------------------------------------------------------------- /docs/documentation.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## Surface Components 4 | 5 | A surface is anywhere an app can express itself through communication or interaction. Each registered components should return a surface component. 6 | 7 | ### Message 8 | 9 |

10 | 11 |

12 | 13 | App-published messages are dynamic yet transient spaces. They allow users to complete workflows among their Slack conversations. 14 | 15 | **Provided Properties:** 16 | 17 | | Properties | Type | 18 | | ---------- | ----------------------------------------- | 19 | | useState | a [useState function](#usestate-function) | 20 | | useModal | a [useModal function](#usemodal-function) | 21 | | props | a JSON serializable object | 22 | 23 | **Component Properties:** 24 | 25 | | Properties | Type | Required | 26 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------- | 27 | | children | array of [Actions](#actions), [Context](#context), [Divider](#divider), [ImageBlock](#imageblock), or [Section](#section) components | yes | 28 | | text | string | no | 29 | 30 | **Example:** 31 | 32 | ```tsx 33 | const imageUrls = [ 34 | "https://cdn.pixabay.com/photo/2015/06/08/15/02/pug-801826__480.jpg", 35 | "https://cdn.pixabay.com/photo/2015/03/26/09/54/pug-690566__480.jpg", 36 | "https://cdn.pixabay.com/photo/2018/03/31/06/31/dog-3277416__480.jpg", 37 | "https://cdn.pixabay.com/photo/2016/02/26/16/32/dog-1224267__480.jpg" 38 | ]; 39 | 40 | function randomImage(): string { 41 | const index = Math.floor(Math.random() * imageUrls.length); 42 | return imageUrls[index]; 43 | } 44 | 45 | export function RandomImage({ useState }: PheliaMessageProps) { 46 | const [imageUrl, setImageUrl] = useState("imageUrl", randomImage()); 47 | 48 | return ( 49 | 50 | 56 | 57 | 58 | 76 | 77 | 78 | ); 79 | } 80 | ``` 81 | 82 | ### Modal 83 | 84 |

85 | 86 |

87 | 88 | Modals provide focused spaces ideal for requesting and collecting data from users, or temporarily displaying dynamic and interactive information. 89 | 90 | **Provided Properties:** 91 | 92 | | Properties | Type | 93 | | ---------- | ----------------------------------------- | 94 | | useState | a [useState function](#usestate-function) | 95 | | props | a JSON serializable object | 96 | 97 | **Component Properties:** 98 | 99 | | Properties | Type | Required | 100 | | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | 101 | | children | array of [Actions](#actions), [Context](#context), [Divider](#divider), [ImageBlock](#imageblock), [Input](#input), or [Section](#section) components | yes | 102 | | title | string or [Text](#text) | yes | 103 | | submit | string or [Text](#text) | no | 104 | | close | string or [Text](#text) | no | 105 | 106 | **Example:** 107 | 108 | ```tsx 109 | export function MyModal({ useState }: PheliaModalProps) { 110 | const [showForm, setShowForm] = useState("showForm", false); 111 | 112 | return ( 113 | 114 | {!showForm && ( 115 | 116 | 119 | 120 | )} 121 | 122 | {showForm && ( 123 | <> 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 150 | 151 | 152 | )} 153 | 154 | ); 155 | } 156 | ``` 157 | 158 | ### Home 159 | 160 |

161 | 162 |

163 | 164 | The Home tab is a persistent, yet dynamic interface for apps that lives within the App Home. 165 | 166 | **Provided Properties:** 167 | 168 | | Properties | Type | 169 | | ---------- | ----------------------------------------- | 170 | | useState | a [useState function](#usestate-function) | 171 | | useModal | a [useModal function](#usemodal-function) | 172 | | user | \*a user object | 173 | 174 | _\*if scope `users:read` is not available, the user object will only contain an `id` property._ 175 | 176 | **Component Properties:** 177 | 178 | | Properties | Type | Required | 179 | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------- | 180 | | children | array of [Actions](#actions), [Context](#context), [Divider](#divider), [ImageBlock](#imageblock), or [Section](#section) components | yes | 181 | | onLoad | [InteractionCallback](#interaction-callback) | no | 182 | 183 | **Example:** 184 | 185 | ````tsx 186 | export function HomeApp({ useState, useModal, user }: PheliaHomeProps) { 187 | const [counter, setCounter] = useState("counter", 0); 188 | const [notifications, setNotifications] = useState("notifications", []); 189 | const [form, setForm] = useState("form"); 190 | 191 | const openModal = useModal("modal", MyModal, (event) => 192 | setForm(JSON.stringify(event.form, null, 2)) 193 | ); 194 | 195 | return ( 196 | { 198 | const notifications = await fetchNotifications(event.user); 199 | setNotifications(notifications); 200 | }} 201 | > 202 |
203 | Hey there {user.username} :wave: 204 | *Counter:* {counter} 205 | *Notifications:* {notifications.length} 206 |
207 | 208 | 209 | 212 | 213 | 216 | 217 | 218 | {form && ( 219 |
220 | {"```\n" + form + "\n```"} 221 |
222 | )} 223 |
224 | ); 225 | } 226 | ```` 227 | 228 | ## Custom Storage 229 | 230 | Phelia uses a custom storage object to store posted messages and their properties such as **state**, **props**, and Component type. The persistance method can be customized by use of the `client.setStorage(storage)` method. 231 | 232 | A storage object must implement the following methods: 233 | 234 | - `set(key: string, value: string): void` 235 | - `get(key: string): string` 236 | 237 | _Storage methods may be asynchronous._ 238 | 239 | By default the storage object is an in-memory map. Here is an example using Redis for storage: 240 | 241 | ```ts 242 | import redis from "redis"; 243 | import { setStorage } from "phelia/core"; 244 | 245 | const client = redis.createClient(); 246 | 247 | setStorage({ 248 | set: (key, value) => 249 | new Promise((resolve, reject) => 250 | client.set(key, value, err => (err ? reject(err) : resolve())) 251 | ), 252 | 253 | get: key => 254 | new Promise((resolve, reject) => 255 | client.get(key, (err, reply) => (err ? reject(err) : resolve(reply))) 256 | ) 257 | }); 258 | ``` 259 | 260 | ## Interactive Webhooks 261 | 262 | In order for Phelia to update your Messages or Modals you must register all of your components and setup up an interactive webhook endpoint. 263 | 264 | ### Registering Components 265 | 266 | Use the `client.registerComponents` method to register your components. You may pass in an array of components: 267 | 268 | ```ts 269 | const client = new Phelia(process.env.SLACK_TOKEN); 270 | client.registerComponents([MyModal, MyMessage]); 271 | ``` 272 | 273 | Pass a function which returns an array of components: 274 | 275 | ```ts 276 | const client = new Phelia(process.env.SLACK_TOKEN); 277 | client.registerComponents(() => [MyModal, MyMessage]); 278 | ``` 279 | 280 | Or pass in a directory which contains all of your components: 281 | 282 | ```ts 283 | import path from "path"; 284 | 285 | const client = new Phelia(process.env.SLACK_TOKEN); 286 | client.registerComponents(path.join(__dirname, "components")); 287 | ``` 288 | 289 | ## Using the `messageHandler` to handle interactive webhook payloads 290 | 291 | Set a **Request URL** and **Options Load URL** in the **Interactivity & Shortcuts** page of your Slack application. You may need to use a reverse proxy like [ngrok](https://ngrok.com/) for local development. 292 | 293 | 294 | 295 | Then use `client.messageHandler()` to intercept these webhook payloads. 296 | 297 | ```ts 298 | const client = new Phelia(process.env.SLACK_TOKEN); 299 | 300 | app.post( 301 | "/interactions", 302 | client.messageHandler(process.env.SLACK_SIGNING_SECRET) 303 | ); 304 | ``` 305 | 306 | ## Registering a Home Tab component 307 | 308 | To use a Home Tab component, register a webhook for **Slacks Events API** and register your Home Tab component with Phelia. 309 | 310 | Make sure that you have selected the `app_home_opened` **bot event** in the **Event Subscriptions** of your Slack application. 311 | 312 | 313 | 314 | Then use `client.appHomeHandler()` to intercept this webhook payload. 315 | 316 | ```ts 317 | import { createEventAdapter } from "@slack/events-api"; 318 | 319 | const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET); 320 | const client = new Phelia(process.env.SLACK_TOKEN); 321 | 322 | slackEvents.on("app_home_opened", client.appHomeHandler(HomeApp)); 323 | 324 | app.use("/events", slackEvents.requestListener()); 325 | ``` 326 | 327 | _This requires use of Slack's SDK `@slack/events-api`_ 328 | 329 | With this setup, whenever a user opens the Home tab it will display your Home App accordingly. 330 | 331 | ## Opening Modals 332 | 333 | Modals can be opened in two ways: 334 | 335 | 1. By use of the `useModal` hook in a component. 336 | 2. In response to a user command or action within Slack. 337 | 338 | When responding to user action outside a phelia component, one can record the `trigger_id` sent by slack in response to a command and use it to open a modal: 339 | 340 | ```ts 341 | 342 | // In response to some slash command from the user. 343 | const triggerID = slackCommandPayload.trigger_id; 344 | const client = new Phelia(process.env.SLACK_TOKEN); 345 | 346 | await client.openModal( 347 | MyModal, 348 | triggerID, 349 | { // props }, 350 | ); 351 | ``` 352 | 353 | ## Injected Properties 354 | 355 | Depending on which type of component you are building, Phelia will inject a collection of functions and properties into your components function. 356 | 357 | ### `useState` Function 358 | 359 | The `useState` function is very similar to it's React predecessor. Given a unique key, `useState` will return a pair of values; the current state and a function to modify the state. The `useState` function also takes an optional second parameter to specify an initial value. 360 | 361 | **Example:** 362 | 363 | ```tsx 364 | function Counter({ useState }) { 365 | const [counter, setCounter] = useState("unique-key", 0); 366 | 367 | return ( 368 | 369 |
370 | *Counter:* {counter} 371 |
372 | 373 | 376 | 377 |
378 | ); 379 | } 380 | ``` 381 | 382 | ### `useModal` Function 383 | 384 | The `useModal` function returns a function to open a modal. Parameters include: 385 | 386 | 1. a unique key 387 | 2. the modal component 388 | 3. a [ModalSubmittedCallback](#modalsubmitted-callback) (executed when a modal is submitted) 389 | 4. an [InteractionCallback](#interaction-callback) (executed when a modal is canceled) 390 | 391 | The function returned can be used to open a modal from within any Interaction Callback. The returned function takes an `props` parameter. When included, the `props` will be injected into the modal component. 392 | 393 | **Example:** 394 | 395 | ```tsx 396 | function ModalExample({ useModal }) { 397 | const openModal = useModal( 398 | "modal", 399 | MyModal, 400 | event => console.log(event.form), 401 | () => console.log("canceled") 402 | ); 403 | 404 | return ( 405 | 406 | 407 | 414 | 415 | 416 | ); 417 | } 418 | ``` 419 | 420 | ### `props` Property 421 | 422 | The `props` is a JSON serializable property injected into either [Modal](#modal) or [Message](#message) components. `props` can be optional passed to either component by their respective constructors. As [described above](#usemodal-function), if when opening a modal and an optional property is provided it will be passed along to the Modal component. Alternatively when using the `client.postMessage` function if a property is provided, it too will be passed along to the Message component. 423 | 424 | **Example:** 425 | 426 | ```tsx 427 | function PropsExample({ props }) { 428 | return ( 429 | 430 |
431 | Hello {props.name} :greet: 432 |
433 |
434 | ); 435 | } 436 | 437 | client.postMessage(PropsExample, "@channel", { name: "Phelia" }); 438 | ``` 439 | 440 | ### `user` Property 441 | 442 | The `user` Property is injected into [Home](#home) components. It describes the user who is viewing the Home Tab taking the form of: 443 | 444 | ```ts 445 | { 446 | id: string; 447 | username: string; 448 | name: string; 449 | team_id: string; 450 | } 451 | ``` 452 | 453 | If the scope `users:read` is not available, only the `id` property will be injected. 454 | 455 | ## Callback Functions 456 | 457 | There are various different types of callback functions but all help you respond to a User interacting with your component. Each callback responds with an `event` object. All callback functions can be asynchronous or return a Promise. 458 | 459 | ### Interaction Callback 460 | 461 | An interaction callback is the simplest type of callback. It's `event` object takes the form of: 462 | 463 | ```ts 464 | user: { 465 | id: string; 466 | username: string; 467 | name: string; 468 | team_id: string; 469 | } 470 | ``` 471 | 472 | ### ModalSubmitted Callback 473 | 474 | When a user submits a modal, the ModalSubmittedCallback will be called with the following `event` object: 475 | 476 | ```ts 477 | form: { 478 | [action: string]: any 479 | } 480 | user: { 481 | id: string; 482 | username: string; 483 | name: string; 484 | team_id: string; 485 | } 486 | ``` 487 | 488 | The `event.form` property is a map representing each Input child's `action` and value. For example the following modal: 489 | 490 | ```tsx 491 | function Modal() { 492 | return ( 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 508 | 509 | 510 | 511 | 512 | 513 | 518 | 519 | 520 | ); 521 | } 522 | ``` 523 | 524 | would create the following `event.form` object: 525 | 526 | ```ts 527 | { 528 | "date": "2020-4-20", 529 | "little-bit": "something the users typed" 530 | "checkboxes": ["option-a"], 531 | "summary": "another thing the users typed" 532 | } 533 | ``` 534 | 535 | ### SearchOptions Callback 536 | 537 | A SearchOptions Callback is invoked when a User types a query within a [MultiSelectMenu](#multiselectmenu) or [SelectMenu](#selectmenu) component. It must return either an array of [Options](#option) or [OptionGroups](#optiongroup). It's `event` object takes the form of: 538 | 539 | ```ts 540 | query: string; 541 | user: { 542 | id: string; 543 | username: string; 544 | name: string; 545 | team_id: string; 546 | } 547 | ``` 548 | 549 | ### SelectDate Callback 550 | 551 | Used when a User selects a [DatePicker](#datepicker). The `event` object takes the form of: 552 | 553 | ```ts 554 | date: string; 555 | user: { 556 | id: string; 557 | username: string; 558 | name: string; 559 | team_id: string; 560 | } 561 | ``` 562 | 563 | ### SelectOption Callback 564 | 565 | Used when a User selects a single option. The `event` object takes the form of: 566 | 567 | ```ts 568 | selected: string; 569 | user: { 570 | id: string; 571 | username: string; 572 | name: string; 573 | team_id: string; 574 | } 575 | ``` 576 | 577 | ### SelectOptions Callback 578 | 579 | Used when a User selects multiple options. The `event` object takes the form of: 580 | 581 | ```ts 582 | selected: string[]; 583 | user: { 584 | id: string; 585 | username: string; 586 | name: string; 587 | team_id: string; 588 | } 589 | ``` 590 | 591 | ## Block Components 592 | 593 | Blocks are a series of components that can be combined to create visually rich and compellingly interactive messages. 594 | 595 | ### Actions 596 | 597 | A block that is used to hold interactive elements. 598 | 599 | **Component Properties:** 600 | 601 | | Properties | Type | Required | 602 | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | 603 | | children | array of [Button](#button), [SelectMenu](#selectmenu), [RadioButtons](#radiobuttons), [MultiSelectMenu](#multiselectmenu), [Checkboxes](#checkboxes), [OverflowMenu](#overflowmenu), or [DatePicker](#datepicker) components | yes | 604 | 605 | **Example:** 606 | 607 | ```tsx 608 | 609 | 612 | 613 | setDate(event.selected)} action="date" /> 614 | 615 | ``` 616 | 617 | ### Context 618 | 619 | Displays message context, which can include both images and text. 620 | 621 | **Component Properties:** 622 | 623 | | Properties | Type | Required | 624 | | ---------- | ---------------------------------------------------- | -------- | 625 | | children | array of [Image](#image) or [Text](#text) components | yes | 626 | 627 | **Example:** 628 | 629 | ```tsx 630 | 631 | 632 | 633 | ``` 634 | 635 | ### Divider 636 | 637 | A content divider, like an `
`, to split up different blocks inside of a surface. It does not have any properties. 638 | 639 | **Example:** 640 | 641 | ```tsx 642 | 643 | ``` 644 | 645 | ### ImageBlock 646 | 647 | A simple image block. 648 | 649 | **Component Properties:** 650 | 651 | | Properties | Type | Required | 652 | | ---------- | ------- | -------- | 653 | | alt | string | yes | 654 | | emoji | boolean | no | 655 | | imageUrl | string | yes | 656 | | title | string | no | 657 | 658 | **Example:** 659 | 660 | ```tsx 661 | 662 | ``` 663 | 664 | ### Input 665 | 666 | A block that collects information from users 667 | 668 | **Component Properties:** 669 | 670 | | Properties | Type | Required | 671 | | ---------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- | 672 | | children | a [TextField](#textfield), [SelectMenu](#selectmenu), [MultiSelectMenu](#multiselectmenu), or [DatePicker](#datepicker) component | yes | 673 | | hint | string or [Text](#text) | no | 674 | | label | string or [Text](#text) | yes | 675 | | optional | boolean | no | 676 | 677 | **Example:** 678 | 679 | ```tsx 680 | 681 | 682 | 683 | ``` 684 | 685 | ### Section 686 | 687 | A block that collects information from users 688 | 689 | **Component Properties:** 690 | 691 | | Properties | Type | Required | 692 | | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | 693 | | accessory | a [Button](#button), [SelectMenu](#selectmenu), [RadioButtons](#radiobuttons), [MultiSelectMenu](#multiselectmenu), [Checkboxes](#checkboxes), [OverflowMenu](#overflowmenu), or [DatePicker](#datepicker) component | no | 694 | | children | an array of Text components | if the text property is not included | 695 | | text | string or [Text](#text) | if no children are included | 696 | 697 | **Example:** 698 | 699 | ```tsx 700 |
{ 705 | setBirth(date); 706 | setUser(user.username); 707 | }} 708 | action="date" 709 | /> 710 | } 711 | /> 712 | ``` 713 | 714 | ## Block Elements 715 | 716 | ### Button 717 | 718 | An interactive component that inserts a button. The button can be a trigger for anything from opening a simple link to starting a complex workflow. 719 | 720 | **Component Properties:** 721 | 722 | | Properties | Type | Required | 723 | | ---------- | -------------------------------------------- | ---------------------------------- | 724 | | action | string | if an onClick property is provided | 725 | | children | string | yes | 726 | | confirm | [Confirm](#confirm) | no | 727 | | onClick | [InteractionCallback](#interaction-callback) | no | 728 | | style | "danger" or "primary" | no | 729 | | url | string | no | 730 | 731 | **Example:** 732 | 733 | ```tsx 734 | 737 | ``` 738 | 739 | ```tsx 740 | 741 | ``` 742 | 743 | ### Checkboxes 744 | 745 | A checkbox group that allows a user to choose multiple items from a list of possible options. 746 | 747 | **Component Properties:** 748 | 749 | | Properties | Type | Required | 750 | | ---------- | ------------------------------------------------ | -------- | 751 | | action | string | yes | 752 | | children | array of [Option](#option) components | yes | 753 | | confirm | [Confirm](#confirm) | no | 754 | | onSelect | [SelectOptionsCallback](#selectoptions-callback) | no | 755 | 756 | **Example:** 757 | 758 | ```tsx 759 | setSelected(event.selected)}> 760 | 763 | 764 | 765 | ``` 766 | 767 | ### DatePicker 768 | 769 | An element which lets users easily select a date from a calendar style UI 770 | 771 | **Component Properties:** 772 | 773 | | Properties | Type | Required | 774 | | ----------- | ------------------------------------------ | -------- | 775 | | action | string | yes | 776 | | confirm | [Confirm](#confirm) | no | 777 | | initialDate | string | no | 778 | | onSelect | [SelectDateCallback](#selectdate-callback) | no | 779 | | placeholder | string or [Text](#text) | no | 780 | 781 | **Example:** 782 | 783 | ```tsx 784 | setDate(event.date)} 786 | action="date" 787 | initialDate="2020-11-11" 788 | /> 789 | ``` 790 | 791 | ### Image 792 | 793 | An element to insert an image as part of a larger block of content. If you want a block with only an image in it, you're looking for the [Image Block](#imageblock). 794 | 795 | **Component Properties:** 796 | 797 | | Properties | Type | Required | 798 | | ---------- | ------ | -------- | 799 | | imageUrl | string | yes | 800 | | alt | string | yes | 801 | 802 | **Example:** 803 | 804 | ```tsx 805 | an image of a dog 806 | ``` 807 | 808 | ### MultiSelectMenu 809 | 810 | A multi-select menu allows a user to select multiple items from a list of options. 811 | 812 | **Component Properties:** 813 | 814 | | Properties | Type | Required | 815 | | -------------------- | -------------------------------------------------------------------- | ----------------------- | 816 | | action | string | yes | 817 | | placeholder | string or [Text](#text) | yes | 818 | | confirm | [Confirm](#confirm) | no | 819 | | onSelect | [SelectOptionsCallback](#selectoptions-callback) | no | 820 | | maxSelectedItems | integer | no | 821 | | type | "static" "users" "channels" "external" or "conversations" | no | 822 | | children | array of [Option](#option) or [OptionGroup](#optiongroup) components | if "static" type | 823 | | initialUsers | array of User Ids | if "users" type | 824 | | initialChannels | array of Channel Ids | if "channels" type | 825 | | initialOptions | array of Option components | if "external" type | 826 | | onSearchOptions | a [SearchOptionsCallback](#searchoptions-callback) | if "external" type | 827 | | minQueryLength | integer | if "external" type | 828 | | initialConversations | array of Conversation Ids | if "conversations" type | 829 | | filter | a [ConversationFilter](#conversationfilter) object | if "conversations" type | 830 | 831 | **Examples:** 832 | 833 | ```tsx 834 | filterUsers(event.query)} 836 | type="external" 837 | action="select-users" 838 | placeholder="Select a user" 839 | /> 840 | ``` 841 | 842 | ```tsx 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 855 | 856 | 857 | 858 | ``` 859 | 860 | ### OverflowMenu 861 | 862 | Presents a list of options to choose from with no type-ahead field, and the button always appears with an ellipsis ("…") rather than a placeholder. 863 | 864 | **Component Properties:** 865 | 866 | | Properties | Type | Required | 867 | | ---------- | -------------------------------------------------------------------- | -------- | 868 | | action | string | yes | 869 | | children | array of [Option](#option) or [OptionGroup](#optiongroup) components | yes | 870 | | confirm | [Confirm](#confirm) | no | 871 | | onSelect | [SelectOptionCallback](#selectoption-callback) | no | 872 | 873 | **Example:** 874 | 875 | ```tsx 876 | setSelected(event.selected)}> 877 | 878 | 879 | 882 | 883 | ``` 884 | 885 | ### RadioButtons 886 | 887 | A radio button group that allows a user to choose one item from a list of possible options. 888 | 889 | **Component Properties:** 890 | 891 | | Properties | Type | Required | 892 | | ---------- | -------------------------------------------------------------------- | -------- | 893 | | action | string | yes | 894 | | children | array of [Option](#option) or [OptionGroup](#optiongroup) components | yes | 895 | | confirm | [Confirm](#confirm) | no | 896 | | onSelect | [SelectOptionCallback](#selectoption-callback) | no | 897 | 898 | **Example:** 899 | 900 | ```tsx 901 | setSelected(event.selected)} 904 | > 905 | 906 | 909 | 910 | 911 | ``` 912 | 913 | ### SelectMenu 914 | 915 | A select menu creates a drop down menu with a list of options for a user to choose. The select menu also includes type-ahead functionality, where a user can type a part or all of an option string to filter the list. 916 | 917 | **Component Properties:** 918 | 919 | | Properties | Type | Required | 920 | | ------------------- | ----------------------------------------------------------------------- | ----------------------- | 921 | | action | string | yes | 922 | | placeholder | string or [Text](#text) | yes | 923 | | confirm | [Confirm](#confirm) | no | 924 | | onSelect | [SelectOptionCallback](#selectoption-callback) | no | 925 | | type | "static" "users" "channels" "external" or "conversations" | no | 926 | | children | an array of [Option](#option) or [OptionGroup](#optiongroup) components | if "static" type | 927 | | initialUsers | User Ids | if "users" type | 928 | | initialChannel | Channel Ids | if "channels" type | 929 | | initialOption | Option | if "external" type | 930 | | onSearchOptions | a [SearchOptionsCallback](#searchoptions-callback) | if "external" type | 931 | | minQueryLength | integer | if "external" type | 932 | | initialConversation | Conversation Ids | if "conversations" type | 933 | | filter | a [ConversationFilter](#conversationfilter) object | if "conversations" type | 934 | 935 | **Examples:** 936 | 937 | ```tsx 938 | filterUsers(event.query)} 940 | type="external" 941 | action="select-menu" 942 | placeholder="Select a user" 943 | /> 944 | ``` 945 | 946 | ```tsx 947 | setSelected(event.selected)} 952 | /> 953 | ``` 954 | 955 | ### TextField 956 | 957 | A plain-text input creates a field where a user can enter freeform data. It can appear as a single-line field or a larger textarea using the multiline flag. 958 | 959 | **Component Properties:** 960 | 961 | | Properties | Type | Required | 962 | | ------------ | ----------------------- | -------- | 963 | | action | string | yes | 964 | | initialValue | string | no | 965 | | maxLength | integer | no | 966 | | minLength | integer | no | 967 | | multiline | boolean | no | 968 | | placeholder | string or [Text](#text) | no | 969 | 970 | **Example:** 971 | 972 | ```tsx 973 | 974 | ``` 975 | 976 | ## Composition Elements 977 | 978 | Composition Elements are commonly used elements. 979 | 980 | ### Text 981 | 982 | An element with text. 983 | 984 | **Component Properties:** 985 | 986 | | Properties | Type | Required | 987 | | ---------- | ------------------------ | -------------------- | 988 | | children | string | yes | 989 | | emoji | boolean | if "plain_text" type | 990 | | type | "plain_text" or "mrkdwn" | no | 991 | | verbatim | boolean | if "mrkdwn" type | 992 | 993 | **Example:** 994 | 995 | ```tsx 996 | Hello there :wave: 997 | ``` 998 | 999 | ### Confirm 1000 | 1001 | An object that defines a dialog that provides a confirmation step to any interactive element. This dialog will ask the user to confirm their action by offering a confirm and deny buttons. 1002 | 1003 | **Component Properties:** 1004 | 1005 | | Properties | Type | Required | 1006 | | ---------- | ----------------------- | -------- | 1007 | | children | string or [Text](#text) | yes | 1008 | | confirm | string or [Text](#text) | yes | 1009 | | deny | string or [Text](#text) | yes | 1010 | | style | "danger" or "primary" | no | 1011 | | title | string or [Text](#text) | yes | 1012 | 1013 | **Example:** 1014 | 1015 | ```tsx 1016 | 1026 | ``` 1027 | 1028 | ### Option 1029 | 1030 | Represents a single selectable item in a [SelectMenu](#selectmenu), [MultiSelectMenu](#multiselectmenu), [Checkboxes](#checkboxes), [RadioButtons](#radiobuttons), or [OverflowMenu](#overflowmenu). 1031 | 1032 | **Component Properties:** 1033 | 1034 | | Properties | Type | Required | 1035 | | ----------- | ----------------------- | -------- | 1036 | | children | string or [Text](#text) | yes | 1037 | | description | string or [Text](#text) | no | 1038 | | selected | boolean | no | 1039 | | url | string | no | 1040 | | value | string | no | 1041 | 1042 | **Example:** 1043 | 1044 | ```tsx 1045 | 1048 | ``` 1049 | 1050 | ### OptionGroup 1051 | 1052 | Provides a way to group options in a [SelectMenu](#selectmenu) or [MultiSelectMenu](#multiselectmenu) 1053 | 1054 | **Component Properties:** 1055 | 1056 | | Properties | Type | Required | 1057 | | ---------- | ------------------------------------- | -------- | 1058 | | children | array of [Option](#option) components | yes | 1059 | | label | string or [Text](#text) | yes | 1060 | 1061 | **Example:** 1062 | 1063 | ```tsx 1064 | 1065 | 1066 | 1067 | 1068 | 1069 | ``` 1070 | 1071 | ### ConversationFilter 1072 | 1073 | Provides a way to filter the list of Conversations in a [SelectMenu](#selectmenu) or [MultiSelectMenu](#multiselectmenu) 1074 | 1075 | | Properties | Type | Required | 1076 | | ----------------------------- | ------------------------------ | -------- | 1077 | | include | "im" "mpim" "private" "public" | no | 1078 | | excludeBotUsers | boolean | no | 1079 | | excludeExternalSharedChannels | boolean | no | 1080 | 1081 | **Example:** 1082 | 1083 | ```tsx 1084 | 1093 | ``` 1094 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide will show you how to configure Phelia to post an interactive Slack message. 4 | 5 | # Using the example project 6 | Clone and setup the example project: 7 | ```bash 8 | $ git clone https://github.com/maxchehab/phelia-message-example 9 | $ cd phelia-message-example 10 | $ yarn 11 | $ cp .env.example .env 12 | ``` 13 | 14 | ## File overview 15 | 16 | - [`src/server.ts`](https://github.com/maxchehab/phelia-message-example/blob/master/src/server.ts) - A simple express server that handles webhook responses. 17 | - [`src/random-image.tsx`](https://github.com/maxchehab/phelia-message-example/blob/master/src/random-image.tsx) - A message component to display random dog images. 18 | - [`.env`](https://github.com/maxchehab/phelia-message-example/blob/master/.env.example) - A file with necessary environment variables 19 | 20 | # Setting up the Slack App 21 | ## Register and install a Slack App through the app dashboard. 22 | 1. Go to https://api.slack.com/apps and select **Create New App**. 23 | 2. Set a **Request URL** and an **Options Load URL** in the **Interactivity & Shortcuts** page of your Slack application. You will need to use a reverse proxy like [ngrok](https://ngrok.com) for local development. 24 | ![setting up interactive webhooks](https://raw.githubusercontent.com/maxchehab/phelia/master/screenshots/interactive-webhook-setup.png) 25 | 3. Under **OAuth & Permissions** add a `chat:write` scope under "bot token scopes". After you install the app into a workspace you can grab the **Bot User OAuth Access Token**. Save this value as the `SLACK_TOKEN` value in your `.env`. 26 | 4. Back under **Basic Information** you will need to grab your **Signing Secret**. Save this value as the `SLACK_SIGNING_SECRET` in your `env`. 27 | 28 | # Posting the message 29 | By now you should have `SLACK_SIGNING_SECRET` and `SLACK_TOKEN` variables saved in the `.env` and an interaction endpoint should be registered with Slack to handle user interactions. All that is left is grabbing your **Member ID** to post a message. 30 | 31 | 1. In your Slack workspace, select **View Profile** in the top left dropdown: 32 | ![select view profile in the top left dropdown](https://raw.githubusercontent.com/maxchehab/phelia/master/screenshots/view%20profile.png) 33 | 2. Copy your **Member ID** from the multimenu select within your profile: 34 | ![select view profile in the top left dropdown](https://raw.githubusercontent.com/maxchehab/phelia/master/screenshots/copy-member-id.png) 35 | 36 | Pass your Member ID as the second parameter to `client.postMessage` in [`src/server.ts`](https://github.com/maxchehab/phelia-message-example/blob/83d5a1ee75423253fa54980d68b98f76016cebd0/src/server.ts#L22-L23) 37 | ```diff 38 | - client.postMessage(RandomImage, "YOUR MEMBER ID HERE"); 39 | + client.postMessage(RandomImage, "U012089FBMY"); 40 | ``` 41 | 42 | Now in the root of the project, run `yarn start`. You should immediately receive a message from your bot. 43 | ![slack message](https://raw.githubusercontent.com/maxchehab/phelia/master/screenshots/message.png) 44 | 45 | 46 | If you run into any issues [join our community Slack](https://join.slack.com/t/phelia/shared_invite/zt-dm4ln2w5-6aOXvv5ewiifDJGsplcVjA) and we will try to get you sorted out 😃. If you are interested in learning more about available components you should read the general [Documentation](https://github.com/maxchehab/phelia/wiki/Documentation). 47 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['/src'], 3 | transform: { 4 | '^.+\\.tsx?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$', 7 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phelia", 3 | "version": "0.1.11", 4 | "main": "dist/index.js", 5 | "license": "MIT", 6 | "repository": "https://github.com/maxchehab/phelia", 7 | "devDependencies": { 8 | "@types/express": "^4.17.4", 9 | "@types/jest": "^25.2.1", 10 | "@types/node": "12.7.0", 11 | "@types/react": "^16.9.32", 12 | "@typescript-eslint/eslint-plugin": "^2.28.0", 13 | "@typescript-eslint/parser": "^2.28.0", 14 | "eslint": "^6.8.0", 15 | "eslint-plugin-jsdoc": "^23.0.0", 16 | "jest": "^25.2.7", 17 | "rimraf": "^3.0.2", 18 | "ts-jest": "^25.3.1", 19 | "tslint": "^6.1.1", 20 | "typescript": "^3.8.3" 21 | }, 22 | "dependencies": { 23 | "@slack/events-api": "^2.3.2", 24 | "@slack/interactive-messages": "^1.5.0", 25 | "@slack/web-api": "^5.8.0", 26 | "@types/react-dom": "^16.9.6", 27 | "@types/react-reconciler": "^0.18.0", 28 | "dotenv": "^8.2.0", 29 | "express": "^4.17.1", 30 | "react": "^16.13.1", 31 | "react-dom": "^16.13.1", 32 | "react-reconciler": "^0.25.1", 33 | "ts-xor": "^1.0.8" 34 | }, 35 | "scripts": { 36 | "test": "jest", 37 | "lint": "eslint src --ext ts --ext tsx", 38 | "build": "tsc", 39 | "clean": "yarn rimraf dist", 40 | "prestart": "yarn build", 41 | "prepublish": "yarn lint && yarn test && yarn clean && yarn build --project prod.tsconfig.json", 42 | "start": "node dist/example/server.js" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /prod.tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "target": "es6", 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "outDir": "dist", 10 | "declaration": true, 11 | "baseUrl": "." 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules/**/*", "src/example/**/*", "src/tests/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /screenshots/copy-member-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/copy-member-id.png -------------------------------------------------------------------------------- /screenshots/doggies.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/doggies.gif -------------------------------------------------------------------------------- /screenshots/hero.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/hero.gif -------------------------------------------------------------------------------- /screenshots/home-app-webhook-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/home-app-webhook-setup.png -------------------------------------------------------------------------------- /screenshots/home-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/home-app.png -------------------------------------------------------------------------------- /screenshots/interactive-webhook-setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/interactive-webhook-setup.png -------------------------------------------------------------------------------- /screenshots/message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/message.png -------------------------------------------------------------------------------- /screenshots/modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/modal.png -------------------------------------------------------------------------------- /screenshots/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/screenshot1.png -------------------------------------------------------------------------------- /screenshots/view profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxchehab/phelia/60218b0c9e4af33b0916af6d1e7a1d496ff909e5/screenshots/view profile.png -------------------------------------------------------------------------------- /src/@types/jsx.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace JSX { 2 | type ComponentType = 3 | | "actions" 4 | | "button" 5 | | "checkboxes" 6 | | "confirm" 7 | | "context" 8 | | "divider" 9 | | "home" 10 | | "image-block" 11 | | "image" 12 | | "input" 13 | | "message" 14 | | "modal" 15 | | "multi-select-menu" 16 | | "option-group" 17 | | "option" 18 | | "overflow" 19 | | "radio-buttons" 20 | | "section" 21 | | "select-menu" 22 | | "text-field" 23 | | "text"; 24 | 25 | type ToSlackElement = ( 26 | props: any, 27 | reconcile: ( 28 | element: React.FunctionComponentElement 29 | ) => [any, Promise[]], 30 | promises: Promise[] 31 | ) => any | ((props: any) => any); 32 | 33 | interface ComponentProps { 34 | componentType: ComponentType; 35 | toSlackElement: ToSlackElement; 36 | } 37 | 38 | interface IntrinsicElements { 39 | component: ComponentProps; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/core/components.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { 3 | ActionsBlock, 4 | Button as SlackButton, 5 | ContextBlock, 6 | Datepicker, 7 | DividerBlock, 8 | ImageBlock as SlackImageBlock, 9 | ImageElement, 10 | InputBlock, 11 | Option as SlackOption, 12 | PlainTextInput, 13 | SectionBlock, 14 | } from "@slack/web-api"; 15 | import { XOR } from "ts-xor"; 16 | import { 17 | InteractionEvent, 18 | MultiSelectOptionEvent, 19 | SearchOptionsEvent, 20 | SelectDateEvent, 21 | SelectOptionEvent, 22 | SubmitEvent, 23 | } from "./interfaces"; 24 | 25 | type PheliaChild = false | null | undefined | ReactElement | ReactElement[]; 26 | type PheliaChildren = PheliaChild | PheliaChild[]; 27 | 28 | interface TextProps { 29 | /** The content of the text component */ 30 | children: React.ReactText | React.ReactText[]; 31 | /** 32 | * Indicates whether emojis in a text field should be escaped into the colon emoji format. 33 | * This field is only usable when type is plain_text. 34 | */ 35 | emoji?: boolean; 36 | /** The formatting to use for this text object. */ 37 | type: "plain_text" | "mrkdwn"; 38 | /** 39 | * When set to false (as is default) URLs will be auto-converted into links, 40 | * conversation names will be link-ified, and certain mentions will be automatically 41 | * parsed. Using a value of true will skip any preprocessing of this nature, although 42 | * you can still include manual parsing strings. This field is only usable when type is 43 | * mrkdwn. 44 | */ 45 | verbatim?: boolean; 46 | } 47 | 48 | /** 49 | * An component containing some text, formatted either as plain_text or using mrkdwn, 50 | * our proprietary textual markup that's just different enough from Markdown to frustrate you. 51 | */ 52 | export const Text = (props: TextProps) => ( 53 | { 57 | const instance: any = { type: props.type, text: "" }; 58 | 59 | if (props.type === "mrkdwn") { 60 | instance.verbatim = props.verbatim; 61 | } else if (props.type === "plain_text") { 62 | instance.emoji = props.emoji; 63 | } 64 | 65 | return instance; 66 | }} 67 | /> 68 | ); 69 | 70 | Text.defaultProps = { 71 | type: "plain_text", 72 | }; 73 | 74 | interface ButtonBase { 75 | /** The text inside the button. */ 76 | children: string; 77 | /** 78 | * A Confirm component that defines an optional confirmation dialog 79 | * after the button is clicked. 80 | */ 81 | confirm?: ReactElement; 82 | /** 83 | * Indicates whether emojis in the button should be escaped into the colon emoji format. 84 | */ 85 | emoji?: boolean; 86 | /** Decorates buttons with alternative visual color schemes. Use this option with restraint. */ 87 | style?: undefined | "danger" | "primary"; 88 | /** 89 | * A URL to load in the user's browser when the button is clicked. 90 | * Maximum length for this field is 3000 characters. If you're using 91 | * url, you'll still receive an interaction payload and will need to 92 | * send an acknowledgement response. 93 | */ 94 | url?: string; 95 | } 96 | 97 | interface ButtonWithOnClick extends ButtonBase { 98 | /** A callback ran when the button is clicked */ 99 | onClick: (event: InteractionEvent) => void | Promise; 100 | /** 101 | * An identifier for this action. You can use this when you receive an 102 | * interaction payload to identify the source of the action. Should be 103 | * unique among all other action_ids used elsewhere by your app. Maximum 104 | * length for this field is 255 characters. 105 | */ 106 | action: string; 107 | } 108 | 109 | type ButtonProps = XOR; 110 | 111 | /** 112 | * An interactive component that inserts a button. The button can be a trigger for 113 | * anything from opening a simple link to starting a complex workflow. 114 | * 115 | * Works with block types: Section, Actions 116 | */ 117 | export const Button = (props: ButtonProps) => ( 118 | { 122 | const instance: SlackButton = { 123 | type: "button", 124 | action_id: props.action, 125 | style: props.style, 126 | url: props.url, 127 | text: { type: "plain_text", text: "", emoji: props.emoji }, 128 | }; 129 | 130 | const [confirm, confirmPromises] = reconcile(props.confirm); 131 | 132 | instance.confirm = confirm; 133 | promises.push(...confirmPromises); 134 | 135 | return instance; 136 | }} 137 | /> 138 | ); 139 | 140 | type SectionProps = 141 | | { 142 | /** One of the available components. */ 143 | accessory?: ReactElement; 144 | /** The head/title text component */ 145 | text: ReactElement | string; 146 | /** Up to 10 child components */ 147 | children?: PheliaChildren; 148 | } 149 | | { 150 | /** One of the available components. */ 151 | accessory?: ReactElement; 152 | /** The head/title text component */ 153 | text?: ReactElement | string; 154 | /** Up to 10 child components */ 155 | children: PheliaChildren; 156 | }; 157 | 158 | /** 159 | * A section is one of the most flexible components available - it can be used as a 160 | * simple text block, in combination with text fields, or side-by-side with any 161 | * of the available block elements. 162 | * 163 | * Available in surfaces: Modals, Messages, Home tabs 164 | */ 165 | export const Section = (props: SectionProps) => ( 166 | { 170 | const instance: SectionBlock = { 171 | type: "section", 172 | }; 173 | const [accessory, accessoryPromises] = reconcile(props.accessory); 174 | const [text, textPromises] = reconcile(props.text); 175 | 176 | instance.text = text; 177 | instance.accessory = accessory; 178 | 179 | if (instance.text && text.type === "text") { 180 | instance.text.type = "plain_text"; 181 | } 182 | 183 | promises.push(...accessoryPromises, ...textPromises); 184 | 185 | return instance; 186 | }} 187 | /> 188 | ); 189 | 190 | interface ActionsProps { 191 | /** 192 | * An array of interactive element objects - buttons, select menus, 193 | * overflow menus, or date pickers. There is a maximum of 5 elements 194 | * in each action block. 195 | */ 196 | children: PheliaChildren; 197 | } 198 | 199 | /** 200 | * A block that is used to hold interactive elements. 201 | * 202 | * Available in surfaces: Modals, Messages, Home tabs 203 | */ 204 | export const Actions = (props: ActionsProps) => ( 205 | ({ 209 | type: "actions", 210 | elements: [], 211 | })} 212 | /> 213 | ); 214 | 215 | interface ImageProps { 216 | /** The URL of the image to be displayed. Maximum length for this field is 3000 characters. */ 217 | imageUrl: string; 218 | /** 219 | * A plain-text summary of the image. This should not contain any markup. Maximum length for 220 | * this field is 2000 characters. 221 | */ 222 | alt: string; 223 | } 224 | 225 | /** 226 | * A simple image block, designed to make those cat photos really pop. 227 | * 228 | * Available in surfaces: Modals, Messages, Home tabs 229 | */ 230 | export const Image = (props: ImageProps) => ( 231 | ({ 235 | type: "image", 236 | image_url: props.imageUrl, 237 | alt_text: props.alt, 238 | })} 239 | /> 240 | ); 241 | 242 | interface ImageBlockProps { 243 | /** The URL of the image to be displayed. Maximum length for this field is 3000 characters. */ 244 | imageUrl: string; 245 | /** 246 | * A plain-text summary of the image. This should not contain any markup. Maximum length for 247 | * this field is 2000 characters. 248 | */ 249 | alt: string; 250 | /** Whether to enable the emoji prop on the title Text component */ 251 | emoji?: boolean; 252 | /** A title for the image block */ 253 | title?: string; 254 | } 255 | 256 | /** 257 | * An component to insert an image as part of a larger block of content. If you 258 | * want a block with only an image in it, you're looking for the Image component. 259 | * 260 | * Works with block types: Section, Context 261 | */ 262 | export const ImageBlock = (props: ImageBlockProps) => ( 263 | { 267 | const instance: any = { 268 | type: "image", 269 | image_url: props.imageUrl, 270 | alt_text: props.alt, 271 | }; 272 | 273 | if (props.title) { 274 | instance.title = { 275 | type: "plain_text", 276 | text: props.title, 277 | emoji: props.emoji, 278 | }; 279 | } 280 | 281 | return instance; 282 | }} 283 | /> 284 | ); 285 | 286 | /** 287 | * A content divider, like an
, to split up different blocks inside of a 288 | * message. The divider block is nice and neat. 289 | * 290 | * Available in surfaces: Modals, Messages, Home tabs 291 | */ 292 | export const Divider = () => ( 293 | ({ type: "divider" })} 296 | /> 297 | ); 298 | 299 | interface ContextProps { 300 | /** Child components to display within the Context */ 301 | children: PheliaChildren; 302 | } 303 | 304 | /** 305 | * Displays message context, which can include both images and text. 306 | * 307 | * Available in surfaces: Modals, Messages, Home tabs 308 | */ 309 | export const Context = (props: ContextProps) => ( 310 | ({ type: "context", elements: [] })} 314 | /> 315 | ); 316 | 317 | interface ConfirmProps { 318 | /** Components to display within the confirm dialog. */ 319 | children: ReactElement | string; 320 | /** 321 | * A plain_text-only Text component to define the text of the button that confirms the 322 | * action. Maximum length for the text in this field is 30 characters. 323 | */ 324 | confirm: ReactElement | string; 325 | /** 326 | * A plain_text-only Text component to define the text of the button that cancels the 327 | * action. Maximum length for the text in this field is 30 characters. 328 | */ 329 | deny: ReactElement | string; 330 | /** Defines the color scheme applied to the confirm button. A value of danger will display 331 | * the button with a red background on desktop, or red text on mobile. A value of primary 332 | * will display the button with a green background on desktop, or blue text on mobile. 333 | * If this field is not provided, the default value will be primary. 334 | */ 335 | style?: "danger" | "primary"; 336 | /** 337 | * A plain_text-only Text component that defines the dialog's title. Maximum length for this 338 | * field is 100 characters. 339 | */ 340 | title: ReactElement | string; 341 | } 342 | 343 | /** 344 | * A component that defines a dialog that provides a confirmation step to any interactive 345 | * component. This dialog will ask the user to confirm their action by offering a confirm 346 | * and deny buttons. 347 | */ 348 | export const Confirm = (props: ConfirmProps) => ( 349 | { 353 | const instance: any = { 354 | // using a function so the appendInitialChild can determine the type of the component 355 | // whereas slack forbids a confirm object to have a 'type' property 356 | isConfirm: () => true, 357 | 358 | style: props.style, 359 | }; 360 | 361 | const [title, titlePromises] = reconcile(props.title); 362 | const [confirm, confirmPromises] = reconcile(props.confirm); 363 | const [deny, denyPromises] = reconcile(props.deny); 364 | 365 | instance.title = title; 366 | instance.confirm = confirm; 367 | instance.deny = deny; 368 | 369 | instance.title.type = "plain_text"; 370 | instance.confirm.type = "plain_text"; 371 | instance.deny.type = "plain_text"; 372 | 373 | promises.push(...titlePromises, ...confirmPromises, ...denyPromises); 374 | 375 | return instance; 376 | }} 377 | /> 378 | ); 379 | 380 | interface OptionProps { 381 | /** 382 | * A Text component that defines the text shown in the option on the menu. Overflow, select, 383 | * and multi-select menus can only use plain_text objects, while radio buttons and checkboxes 384 | * can use mrkdwn text objects. Maximum length for the text in this field is 75 characters. 385 | */ 386 | children: ReactElement | string; 387 | /** 388 | * The string value that will be passed to your app when this option is chosen. Maximum 389 | * length for this field is 75 characters. 390 | */ 391 | value: string; 392 | /** 393 | * A plain_text only Text component that defines a line of descriptive text shown below 394 | * the text field beside the radio button. Maximum length for the text object within this 395 | * field is 75 characters. 396 | */ 397 | description?: ReactElement | string; 398 | /** 399 | * A URL to load in the user's browser when the option is clicked. The url attribute is only 400 | * available in overflow menus. Maximum length for this field is 3000 characters. If you're 401 | * using url, you'll still receive an interaction payload and will need to send an acknowledgement 402 | * response. 403 | */ 404 | url?: string; 405 | /** Whether the Option is selected. */ 406 | selected?: boolean; 407 | } 408 | 409 | /** 410 | * A component that represents a single selectable item in a select menu, multi-select menu, 411 | * checkbox group, radio button group, or overflow menu. 412 | */ 413 | export const Option = (props: OptionProps) => ( 414 | => { 418 | const instance: any = { 419 | isSelected: () => props.selected, 420 | isOption: () => true, 421 | value: props.value, 422 | url: props.url, 423 | }; 424 | 425 | const [description, descriptionPromises] = reconcile(props.description); 426 | 427 | instance.description = description; 428 | 429 | if (instance.description) { 430 | instance.description.type = "plain_text"; 431 | } 432 | 433 | promises.push(...descriptionPromises); 434 | 435 | return instance; 436 | }} 437 | /> 438 | ); 439 | 440 | interface DatePickerProps { 441 | /** 442 | * An identifier for the action triggered when a menu option is selected. You can use 443 | * this when you receive an interaction payload to identify the source of the action. 444 | * Should be unique among all other action_ids used elsewhere by your app. Maximum length 445 | * for this field is 255 characters. 446 | */ 447 | action: string; 448 | /** 449 | * A Confirm component that defines an optional confirmation dialog that appears after a 450 | * date is selected. 451 | */ 452 | confirm?: ReactElement; 453 | /** 454 | * The initial date that is selected when the element is loaded. This should be in the 455 | * format YYYY-MM-DD. 456 | */ 457 | initialDate?: string; 458 | /** A callback for when a date is selected */ 459 | onSelect?: (event: SelectDateEvent) => void | Promise; 460 | /** 461 | * A plain_text only Text component that defines the placeholder text shown on the datepicker. 462 | * Maximum length for the text in this field is 150 characters. 463 | */ 464 | placeholder?: ReactElement | string; 465 | } 466 | 467 | /** 468 | * An element which lets users easily select a date from a calendar style UI. 469 | * 470 | * Works with block types: Section, Actions, Input 471 | */ 472 | export const DatePicker = (props: DatePickerProps) => ( 473 | { 477 | const instance: Datepicker = { 478 | type: "datepicker", 479 | initial_date: props.initialDate, 480 | action_id: props.action, 481 | }; 482 | 483 | const [placeholder, placeholderPromises] = reconcile(props.placeholder); 484 | const [confirm, confirmPromises] = reconcile(props.confirm); 485 | 486 | instance.placeholder = placeholder; 487 | instance.confirm = confirm; 488 | 489 | if (instance.placeholder) { 490 | instance.placeholder.type = "plain_text"; 491 | } 492 | 493 | promises.push(...placeholderPromises, ...confirmPromises); 494 | 495 | return instance; 496 | }} 497 | /> 498 | ); 499 | 500 | interface MessageProps { 501 | /** Array of Actions, Context, Divider, ImageBlock, or Section components. */ 502 | children: PheliaChildren; 503 | /** The head/title text message. */ 504 | text?: string; 505 | } 506 | 507 | /** 508 | * App-published messages are dynamic yet transient spaces. They allow users to 509 | * complete workflows among their Slack conversations. 510 | */ 511 | export const Message = (props: MessageProps) => ( 512 | ({ blocks: [], text })} 516 | /> 517 | ); 518 | 519 | interface BaseModalProps { 520 | /** Array of Actions, Context, Divider, ImageBlock, Input, or Section components */ 521 | children: PheliaChildren; 522 | /** The title of the modal. */ 523 | title: ReactElement | string; 524 | /** 525 | * An optional plain_text Text component that defines the text displayed in the submit button 526 | * at the bottom-right of the view. submit is required when an input block is within the 527 | * blocks array. Max length of 24 characters. 528 | */ 529 | submit?: ReactElement | string; 530 | /** 531 | * An optional plain_text Text component that defines the text displayed in the close button 532 | * at the bottom-right of the view. Max length of 24 characters. 533 | */ 534 | close?: ReactElement | string; 535 | 536 | /** 537 | * An optional callback that executes when the modal is submitted. 538 | */ 539 | onSubmit?: (event: SubmitEvent) => Promise; 540 | /** 541 | * An optional callback that executes when the modal is canceled. 542 | */ 543 | onCancel?: (event: InteractionEvent) => Promise; 544 | } 545 | 546 | type RootModalProps = BaseModalProps & { 547 | /** A modal subtype indicating this modal was opened by a shortcut or command. */ 548 | type: "root"; 549 | /** 550 | * An optional callback that executes when the modal is submitted. 551 | */ 552 | onSubmit?: (event: SubmitEvent) => Promise; 553 | /** 554 | * An optional callback that executes when the modal is canceled. 555 | */ 556 | onCancel?: (event: InteractionEvent) => Promise; 557 | } 558 | 559 | type InlineModalProps = BaseModalProps & { 560 | /** A modal subtype indicating this modal was opened by another component. */ 561 | type?: "inline"; 562 | }; 563 | 564 | type ModalProps = RootModalProps | InlineModalProps; 565 | 566 | /** 567 | * Modals provide focused spaces ideal for requesting and collecting data from users, 568 | * or temporarily displaying dynamic and interactive information. 569 | */ 570 | export const Modal = (props: ModalProps) => ( 571 | { 575 | const instance: any = { 576 | type: "modal", 577 | blocks: [], 578 | }; 579 | 580 | const [title, titlePromises] = reconcile(props.title); 581 | const [submit, submitPromises] = reconcile(props.submit); 582 | const [close, closePromises] = reconcile(props.close); 583 | 584 | instance.title = title; 585 | instance.submit = submit; 586 | instance.close = close; 587 | 588 | if (instance.title) { 589 | instance.title.type = "plain_text"; 590 | } 591 | 592 | if (instance.submit) { 593 | instance.submit.type = "plain_text"; 594 | } 595 | 596 | if (instance.close) { 597 | instance.close.type = "plain_text"; 598 | } 599 | 600 | promises.push(...titlePromises, ...submitPromises, ...closePromises); 601 | 602 | return instance; 603 | }} 604 | /> 605 | ); 606 | 607 | interface InputProps { 608 | /** 609 | * A label that appears above an input component in the form of a text component that must 610 | * have type of plain_text. Maximum length for the text in this field is 2000 characters. 611 | */ 612 | label: string | ReactElement; 613 | /** 614 | * A plain-text input element, a select menu element, a multi-select menu element, or a datepicker. 615 | */ 616 | children: ReactElement; 617 | /** 618 | * An optional hint that appears below an input element in a lighter grey. It must be a a text 619 | * component with a type of plain_text. Maximum length for the text in this field is 2000 characters. 620 | */ 621 | hint?: string | ReactElement; 622 | /** 623 | * A boolean that indicates whether the input element may be empty when a user submits the modal. 624 | * 625 | * @default false 626 | */ 627 | optional?: boolean; 628 | } 629 | 630 | /** 631 | * A block that collects information from users - it can hold a plain-text input element, a 632 | * select menu element, a multi-select menu element, or a datepicker. 633 | * 634 | * Read our guide to using modals to learn how input blocks pass information to your app. 635 | * 636 | * Available in surfaces: Modals 637 | */ 638 | export const Input = (props: InputProps) => ( 639 | { 643 | const instance: any = { 644 | type: "input", 645 | optional: props.optional, 646 | }; 647 | 648 | const [hint, hintPromises] = reconcile(props.hint); 649 | const [label, labelPromises] = reconcile(props.label); 650 | 651 | instance.hint = hint; 652 | instance.label = label; 653 | 654 | if (instance.label) { 655 | instance.label.type = "plain_text"; 656 | } 657 | 658 | if (instance.hint) { 659 | instance.hint.type = "plain_text"; 660 | } 661 | 662 | promises.push(...hintPromises, ...labelPromises); 663 | 664 | return instance; 665 | }} 666 | /> 667 | ); 668 | 669 | interface TextFieldProps { 670 | /** 671 | * An identifier for the input value when the parent modal is submitted. You can use 672 | * this when you receive a view_submission payload to identify the value of the input 673 | * element. Should be unique among all other action_ids used elsewhere by your app. Maximum 674 | * length for this field is 255 characters. 675 | */ 676 | action: string; 677 | /** The initial value in the plain-text input when it is loaded. */ 678 | initialValue?: string; 679 | /** 680 | * The maximum length of input that the user can provide. If the user provides more, they 681 | * will receive an error. 682 | */ 683 | maxLength?: number; 684 | /** 685 | * The minimum length of input that the user must provide. If the user provides less, 686 | * they will receive an error. Maximum value is 3000. 687 | */ 688 | minLength?: number; 689 | /** 690 | * Indicates whether the input will be a single line (false) or a larger textarea (true). 691 | * 692 | * @default false 693 | */ 694 | multiline?: boolean; 695 | /** 696 | * A plain_text only Text component that defines the placeholder text shown in the plain-text 697 | * input. Maximum length for the text in this field is 150 characters. 698 | */ 699 | placeholder?: ReactElement | string; 700 | } 701 | 702 | /** 703 | * A plain-text input, similar to the HTML tag, creates a field where a user can 704 | * enter freeform data. It can appear as a single-line field or a larger textarea using 705 | * the multiline flag. 706 | * 707 | * Plain-text input elements are currently only available in modals. To use them, you will 708 | * need to make some changes to prepare your app. Read about preparing your app for modals. 709 | * 710 | * Works with block types: Section, Actions, Input 711 | */ 712 | export const TextField = (props: TextFieldProps) => ( 713 | { 717 | const instance: PlainTextInput = { 718 | type: "plain_text_input", 719 | initial_value: props.initialValue, 720 | action_id: props.action, 721 | max_length: props.maxLength, 722 | min_length: props.minLength, 723 | multiline: props.multiline, 724 | }; 725 | 726 | const [placeholder, placeholderPromises] = reconcile(props.placeholder); 727 | 728 | instance.placeholder = placeholder; 729 | 730 | if (instance.placeholder) { 731 | instance.placeholder.type = "plain_text"; 732 | } 733 | 734 | promises.push(...placeholderPromises); 735 | 736 | return instance; 737 | }} 738 | /> 739 | ); 740 | 741 | interface CheckboxesProps { 742 | /** 743 | * An identifier for the action triggered when the checkbox group is changed. You can use 744 | * this when you receive an interaction payload to identify the source of the action. Should 745 | * be unique among all other action_ids used elsewhere by your app. Maximum length for this 746 | * field is 255 characters. 747 | */ 748 | action: string; 749 | /** An array of Option components */ 750 | children: PheliaChildren; 751 | /** 752 | * A Confirm component that defines an optional confirmation dialog that appears after clicking 753 | * one of the checkboxes in this element. 754 | */ 755 | confirm?: ReactElement; 756 | /** A callback for when a user selects a checkbox */ 757 | onSelect?: (event: MultiSelectOptionEvent) => void | Promise; 758 | } 759 | 760 | /** 761 | * A checkbox group that allows a user to choose multiple items from a list of possible options. 762 | * 763 | * Checkboxes are only supported in the following app surfaces: Home tabs Modals 764 | * 765 | * Works with block types: Section, Actions, Input 766 | */ 767 | export const Checkboxes = (props: CheckboxesProps) => ( 768 | { 772 | const instance: any = { 773 | type: "checkboxes", 774 | action_id: props.action, 775 | options: [], 776 | }; 777 | 778 | const [{ fields: options }, optionPromises] = reconcile( 779 | React.createElement(Section, { children: props.children }) 780 | ); 781 | const [confirm, confirmPromises] = reconcile(props.confirm); 782 | 783 | if (Array.isArray(options)) { 784 | const selectedOptions = options 785 | .filter((option) => option?.isSelected()) 786 | .map((option) => ({ ...option, url: undefined })); 787 | 788 | instance.initial_options = selectedOptions.length 789 | ? selectedOptions 790 | : undefined; 791 | } 792 | 793 | instance.confirm = confirm; 794 | 795 | promises.push(...optionPromises, ...confirmPromises); 796 | 797 | return instance; 798 | }} 799 | /> 800 | ); 801 | 802 | interface OverflowMenuProps { 803 | /** 804 | * An identifier for the action triggered when a menu option is selected. You can 805 | * use this when you receive an interaction payload to identify the source of the 806 | * action. Should be unique among all other action_ids used elsewhere by your app. 807 | * Maximum length for this field is 255 characters. 808 | */ 809 | action: string; 810 | /** 811 | * An array of Option components to display in the menu. Maximum number of options 812 | * is 5, minimum is 2. 813 | */ 814 | children: PheliaChildren; 815 | /** 816 | * A confirm object that defines an optional confirmation dialog that appears after a 817 | * menu item is selected. 818 | */ 819 | confirm?: ReactElement; 820 | /** A callback called once an Option is selected */ 821 | onSelect?: (event: SelectOptionEvent) => void | Promise; 822 | } 823 | 824 | /** 825 | * This is like a cross between a button and a select menu - when a user clicks on 826 | * this overflow button, they will be presented with a list of options to choose from. 827 | * Unlike the select menu, there is no typeahead field, and the button always appears 828 | * with an ellipsis ("…") rather than customisable text. 829 | * 830 | * As such, it is usually used if you want a more compact layout than a select menu, 831 | * or to supply a list of less visually important actions after a row of buttons. You 832 | * can also specify simple URL links as overflow menu options, instead of actions. 833 | * 834 | * Works with block types: Section, Actions 835 | */ 836 | export const OverflowMenu = (props: OverflowMenuProps) => ( 837 | { 841 | const instance: any = { 842 | type: "overflow", 843 | action_id: props.action, 844 | options: [], 845 | }; 846 | 847 | const [confirm, confirmPromises] = reconcile(props.confirm); 848 | 849 | instance.confirm = confirm; 850 | 851 | promises.push(...confirmPromises); 852 | 853 | return instance; 854 | }} 855 | /> 856 | ); 857 | 858 | interface RadioButtonsProps { 859 | /** 860 | * An identifier for the action triggered when the radio button group is changed. You can 861 | * use this when you receive an interaction payload to identify the source of the action. 862 | * Should be unique among all other action_ids used elsewhere by your app. Maximum length 863 | * for this field is 255 characters. 864 | */ 865 | action: string; 866 | /** Option component(s) */ 867 | children: PheliaChildren; 868 | /** 869 | * A Confirm component that defines an optional confirmation dialog that appears after clicking 870 | * one of the radio buttons in this element. 871 | */ 872 | confirm?: ReactElement; 873 | /** A callback called once an Option is selected */ 874 | onSelect?: (event: SelectOptionEvent) => void | Promise; 875 | } 876 | 877 | /** 878 | * Radio buttons are only supported in the following app surfaces: Home tabs Modals 879 | * 880 | * A radio button group that allows a user to choose one item from a list of possible options. 881 | * 882 | * Works with block types: Section, Actions, Input 883 | */ 884 | export const RadioButtons = (props: RadioButtonsProps) => ( 885 | { 889 | const instance: any = { 890 | type: "radio_buttons", 891 | action_id: props.action, 892 | options: [], 893 | }; 894 | 895 | const [{ fields: options }, optionPromises] = reconcile( 896 | React.createElement(Section, { children: props.children }) 897 | ); 898 | const [confirm, confirmPromises] = reconcile(props.confirm); 899 | 900 | if (Array.isArray(options)) { 901 | const selectedOption = options 902 | .map((option) => ({ 903 | ...option, 904 | url: undefined, 905 | })) 906 | .find((option) => option?.isSelected()); 907 | 908 | instance.initial_option = selectedOption; 909 | } 910 | 911 | instance.confirm = confirm; 912 | 913 | promises.push(...optionPromises, ...confirmPromises); 914 | return instance; 915 | }} 916 | /> 917 | ); 918 | 919 | interface OptionGroupProps { 920 | /** 921 | * A plain_text only Text component that defines the label shown above this 922 | * group of options. Maximum length for the text in this field is 75 characters. 923 | */ 924 | label: ReactElement | string; 925 | /** 926 | * An array of Option components that belong to this specific group. Maximum of 100 items. 927 | */ 928 | children: PheliaChildren; 929 | } 930 | 931 | /** Provides a way to group options in a select menu or multi-select menu. */ 932 | export const OptionGroup = (props: OptionGroupProps) => ( 933 | { 937 | const instance: any = { 938 | isOptionGroup: () => true, 939 | options: [], 940 | }; 941 | 942 | const [label, labelPromises] = reconcile(props.label); 943 | 944 | instance.label = label; 945 | 946 | if (instance.label) { 947 | instance.label.type = "plain_text"; 948 | } 949 | 950 | promises.push(...labelPromises); 951 | 952 | return instance; 953 | }} 954 | /> 955 | ); 956 | 957 | interface SelectMenuBase { 958 | /** 959 | * An identifier for the action triggered when a menu option is selected. You can 960 | * use this when you receive an interaction payload to identify the source of the 961 | * action. Should be unique among all other action_ids used elsewhere by your app. 962 | * Maximum length for this field is 255 characters. 963 | */ 964 | action: string; 965 | /** 966 | * A plain_text only Text component that defines the placeholder text shown on 967 | * the menu. Maximum length for the text in this field is 150 characters. 968 | */ 969 | placeholder: ReactElement | string; 970 | /** 971 | * A Confirm component that defines an optional confirmation dialog that 972 | * appears after a menu item is selected. 973 | */ 974 | confirm?: ReactElement; 975 | /** Callback for when an option is selected */ 976 | onSelect?: (event: SelectOptionEvent) => void | Promise; 977 | } 978 | 979 | interface StaticSelectMenu extends SelectMenuBase { 980 | /** The type of the select */ 981 | type: "static"; 982 | /** An array of Option components. Maximum number of options is 100. */ 983 | children: PheliaChildren; 984 | } 985 | 986 | interface UserSelectMenu extends SelectMenuBase { 987 | /** The type of the select */ 988 | type: "users"; 989 | /** The user ID of any valid user to be pre-selected when the menu loads. */ 990 | initialUser?: string; 991 | } 992 | 993 | interface ChannelSelectMenu extends SelectMenuBase { 994 | /** The type of the select */ 995 | type: "channels"; 996 | /** The ID of any valid public channel to be pre-selected when the menu loads. */ 997 | initialChannel?: string; 998 | } 999 | 1000 | export type SearchOptions = ( 1001 | event: SearchOptionsEvent 1002 | ) => ReactElement[] | Promise; 1003 | 1004 | interface ExternalSelectMenu extends SelectMenuBase { 1005 | /** The type of the select */ 1006 | type: "external"; 1007 | /** 1008 | * A single option that exactly matches one of the options within the options 1009 | * or option_groups loaded from the external data source. This option will 1010 | * be selected when the menu initially loads. 1011 | */ 1012 | initialOption?: ReactElement; 1013 | /** Called when a user is search the menu options. Should return result options */ 1014 | onSearchOptions: SearchOptions; 1015 | /** 1016 | * When the typeahead field is used, a request will be sent on every character 1017 | * change. If you prefer fewer requests or more fully ideated queries, use the 1018 | * min_query_length attribute to tell Slack the fewest number of typed characters 1019 | * required before dispatch. 1020 | * 1021 | * @default 3 1022 | */ 1023 | minQueryLength?: number; 1024 | } 1025 | 1026 | interface FilterOptions { 1027 | /** 1028 | * Indicates which type of conversations should be included in the list. When 1029 | * this field is provided, any conversations that do not match will be excluded 1030 | */ 1031 | include?: ("im" | "mpim" | "private" | "public")[]; 1032 | /** 1033 | * Indicates whether to exclude external shared channels from conversation lists 1034 | * 1035 | * @default false 1036 | */ 1037 | excludeExternalSharedChannels?: boolean; 1038 | /** 1039 | * Indicates whether to exclude bot users from conversation lists. 1040 | * 1041 | * @default false 1042 | */ 1043 | excludeBotUsers?: boolean; 1044 | } 1045 | 1046 | interface ConversationSelectMenu extends SelectMenuBase { 1047 | /** The type of the select */ 1048 | type: "conversations"; 1049 | /** The ID of any valid conversation to be pre-selected when the menu loads. */ 1050 | initialConversation?: string; 1051 | /** 1052 | * A filter object that reduces the list of available conversations using the 1053 | * specified criteria. 1054 | */ 1055 | filter?: FilterOptions; 1056 | } 1057 | 1058 | type SelectMenuProps = 1059 | | ChannelSelectMenu 1060 | | ConversationSelectMenu 1061 | | ExternalSelectMenu 1062 | | StaticSelectMenu 1063 | | UserSelectMenu; 1064 | 1065 | /** 1066 | * A select menu, just as with a standard HTML 20 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export function ChannelsSelectMenuExample({ 31 | useModal, 32 | useState 33 | }: PheliaMessageProps) { 34 | const [form, setForm] = useState("form"); 35 | const [selected, setSelected] = useState("selected"); 36 | 37 | const openModal = useModal("modal", ChannelsSelectMenuModal, form => { 38 | setForm(JSON.stringify(form, null, 2)); 39 | }); 40 | 41 | return ( 42 | 43 | {selected && ( 44 |
45 | *Selected:* {selected} 46 |
47 | )} 48 | 49 | {!selected && ( 50 | <> 51 | 52 | setSelected(event.selected)} 57 | /> 58 | 59 | 60 | )} 61 | 62 | 63 | 64 |
openModal()}> 68 | Open modal 69 | 70 | } 71 | /> 72 | 73 | {form && ( 74 |
75 | {"```\n" + form + "\n```"} 76 |
77 | )} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/example/example-messages/conversations-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Text, 6 | Input, 7 | Message, 8 | Modal, 9 | PheliaMessageProps, 10 | Section, 11 | Divider, 12 | Actions, 13 | SelectMenu 14 | } from "../../core"; 15 | 16 | export function ConversationsSelectMenuModal() { 17 | return ( 18 | 19 | 20 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export function ConversationsSelectMenuExample({ 32 | useModal, 33 | useState 34 | }: PheliaMessageProps) { 35 | const [form, setForm] = useState("form"); 36 | const [selected, setSelected] = useState("selected"); 37 | 38 | const openModal = useModal("modal", ConversationsSelectMenuModal, form => { 39 | setForm(JSON.stringify(form, null, 2)); 40 | }); 41 | 42 | return ( 43 | 44 | {selected && ( 45 |
46 | *Selected:* {selected} 47 |
48 | )} 49 | 50 | {!selected && ( 51 | <> 52 | 53 | setSelected(event.selected)} 58 | /> 59 | 60 | 61 | )} 62 | 63 | 64 | 65 |
openModal()}> 69 | Open modal 70 | 71 | } 72 | /> 73 | 74 | {form && ( 75 |
76 | {"```\n" + form + "\n```"} 77 |
78 | )} 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /src/example/example-messages/counter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Actions, 5 | Button, 6 | Message, 7 | PheliaMessageProps, 8 | Section, 9 | Text 10 | } from "../../core"; 11 | 12 | export function Counter({ 13 | useState, 14 | props 15 | }: PheliaMessageProps<{ name: string }>) { 16 | const [counter, setCounter] = useState("counter", 0); 17 | 18 | return ( 19 | 20 |
21 | 22 | Hello {props.name}, here is your counter {counter} 23 | 24 |
25 | 26 | 29 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/example/example-messages/external-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Text, 6 | Input, 7 | Message, 8 | Modal, 9 | PheliaMessageProps, 10 | Section, 11 | Divider, 12 | Actions, 13 | SelectMenu, 14 | Option, 15 | OptionGroup 16 | } from "../../core"; 17 | 18 | export function ExternalSelectMenuModal() { 19 | return ( 20 | 21 | 22 | [ 25 | 26 | 27 | 28 | ]} 29 | type="external" 30 | action="select-menu" 31 | placeholder="A placeholder" 32 | /> 33 | 34 | 35 | ); 36 | } 37 | 38 | export function ExternalSelectMenuExample({ 39 | useModal, 40 | useState 41 | }: PheliaMessageProps) { 42 | const [form, setForm] = useState("form"); 43 | const [selected, setSelected] = useState("selected"); 44 | 45 | const openModal = useModal("modal", ExternalSelectMenuModal, form => { 46 | setForm(JSON.stringify(form, null, 2)); 47 | }); 48 | 49 | return ( 50 | 51 | {selected && ( 52 |
53 | *Selected:* {selected} 54 |
55 | )} 56 | 57 | {!selected && ( 58 | <> 59 | 60 | 63 | This was loaded asynchronously 64 | 65 | } 66 | onSearchOptions={() => [ 67 | 70 | ]} 71 | type="external" 72 | action="external" 73 | placeholder="A placeholder" 74 | onSelect={event => setSelected(event.selected)} 75 | /> 76 | 77 | 78 | )} 79 | 80 | 81 | 82 |
openModal()}> 86 | Open modal 87 | 88 | } 89 | /> 90 | 91 | {form && ( 92 |
93 | {"```\n" + form + "\n```"} 94 |
95 | )} 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/example/example-messages/greeter.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Actions, 5 | Button, 6 | Message, 7 | PheliaMessageProps, 8 | Section, 9 | Text 10 | } from "../../core"; 11 | 12 | export function Greeter({ useState }: PheliaMessageProps) { 13 | const [name, setName] = useState("name"); 14 | 15 | return ( 16 | 17 |
setName(user.username)} 22 | > 23 | Click me 24 | 25 | } 26 | text={Click the button} 27 | > 28 | *Name:* 29 | {name || ""} 30 |
31 | 32 | 39 | 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/example/example-messages/home-app.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Actions, 3 | Button, 4 | Home, 5 | PheliaHomeProps, 6 | Section, 7 | Text, 8 | } from "../../core"; 9 | import React from "react"; 10 | import { MyModal } from "./modal-example"; 11 | 12 | export function HomeApp({ useState, useModal, user }: PheliaHomeProps) { 13 | const [counter, setCounter] = useState("counter", 0); 14 | const [loaded, setLoaded] = useState("loaded", 0); 15 | const [form, setForm] = useState("form"); 16 | const [updated, setUpdated] = useState("updated", false); 17 | 18 | const openModal = useModal("modal", MyModal, (event) => 19 | setForm(JSON.stringify(event.form, null, 2)) 20 | ); 21 | 22 | return ( 23 | setLoaded(loaded + 1)} 25 | onUpdate={() => setUpdated(true)} 26 | > 27 |
28 | Hey there {user.username} :wave: 29 | *Updated:* {String(updated)} 30 | *Counter:* {counter} 31 | *Loaded:* {loaded} 32 |
33 | 34 | 35 | 38 | 39 | 42 | 43 | 44 | {form && ( 45 |
46 | {"```\n" + form + "\n```"} 47 |
48 | )} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/example/example-messages/index.ts: -------------------------------------------------------------------------------- 1 | export { ModalExample, MyModal } from "./modal-example"; 2 | export { RadioButtonModal, RadioButtonExample } from "./radio-buttons"; 3 | export { BirthdayPicker } from "./birthday-picker"; 4 | export { Counter } from "./counter"; 5 | export { Greeter } from "./greeter"; 6 | export { OverflowMenuExample } from "./overflow-menu"; 7 | export { RandomImage } from "./random-image"; 8 | export { 9 | StaticSelectMenuExample, 10 | StaticSelectMenuModal 11 | } from "./static-select-menu"; 12 | export { 13 | UsersSelectMenuExample, 14 | UsersSelectMenuModal 15 | } from "./users-select-menu"; 16 | export { 17 | ConversationsSelectMenuExample, 18 | ConversationsSelectMenuModal 19 | } from "./conversations-select-menu"; 20 | export { 21 | ChannelsSelectMenuExample, 22 | ChannelsSelectMenuModal 23 | } from "./channels-select-menu"; 24 | export { 25 | ExternalSelectMenuExample, 26 | ExternalSelectMenuModal 27 | } from "./external-select-menu"; 28 | export { 29 | MultiStaticSelectMenuExample, 30 | MultiStaticSelectMenuModal 31 | } from "./multi-static-select-menu"; 32 | export { 33 | MultiExternalSelectMenuExample, 34 | MultiExternalSelectMenuModal 35 | } from "./multi-external-select-menu"; 36 | export { 37 | MultiUsersSelectMenuExample, 38 | MultiUsersSelectMenuModal 39 | } from "./multi-users-select-menu"; 40 | export { 41 | MultiChannelsSelectMenuModal, 42 | MultiChannelsSelectMenuExample 43 | } from "./multi-channels-select-menu"; 44 | export { 45 | MultiConversationsSelectMenuExample, 46 | MultiConversationsSelectMenuModal 47 | } from "./multi-conversations-select-menu"; 48 | export { HomeApp } from "./home-app"; 49 | -------------------------------------------------------------------------------- /src/example/example-messages/modal-example.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Actions, 5 | Button, 6 | Checkboxes, 7 | DatePicker, 8 | Input, 9 | Message, 10 | Modal, 11 | Option, 12 | PheliaMessageProps, 13 | Section, 14 | Text, 15 | TextField, 16 | PheliaModalProps, 17 | } from "../../core"; 18 | 19 | export function MyModal({ useState }: PheliaModalProps) { 20 | const [showForm, setShowForm] = useState("showForm", false); 21 | 22 | return ( 23 | 24 | {!showForm && ( 25 | 26 | 29 | 30 | )} 31 | 32 | {showForm && ( 33 | <> 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 52 | 57 | 58 | 59 | )} 60 | 61 | ); 62 | } 63 | 64 | type State = "submitted" | "canceled" | "init"; 65 | 66 | type Props = { 67 | name: string; 68 | }; 69 | 70 | export function ModalExample({ 71 | useModal, 72 | useState, 73 | props, 74 | }: PheliaMessageProps) { 75 | const [state, setState] = useState("state", "init"); 76 | const [form, setForm] = useState("form", ""); 77 | 78 | const openModal = useModal( 79 | "modal", 80 | MyModal, 81 | (form) => { 82 | setState("submitted"); 83 | setForm(JSON.stringify(form, null, 2)); 84 | }, 85 | () => setState("canceled") 86 | ); 87 | 88 | return ( 89 | 90 |
91 | hey {props.name}! 92 |
93 | 94 | {state === "canceled" && ( 95 |
96 | :no_good: why'd you have to do that 97 |
98 | )} 99 | 100 | {state === "submitted" && ( 101 |
102 | {"```\n" + form + "\n```"} 103 |
104 | )} 105 | 106 | {state !== "init" && ( 107 | 108 | 115 | 116 | )} 117 | 118 | {state === "init" && ( 119 | 120 | 127 | 128 | )} 129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/example/example-messages/multi-channels-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Divider, 6 | Input, 7 | Message, 8 | Modal, 9 | MultiSelectMenu, 10 | PheliaMessageProps, 11 | Section, 12 | Text 13 | } from "../../core"; 14 | 15 | export function MultiChannelsSelectMenuModal() { 16 | return ( 17 | 18 | 19 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export function MultiChannelsSelectMenuExample({ 30 | useModal, 31 | useState 32 | }: PheliaMessageProps) { 33 | const [form, setForm] = useState("form"); 34 | const [selected, setSelected] = useState("selected"); 35 | 36 | const openModal = useModal("modal", MultiChannelsSelectMenuModal, form => { 37 | setForm(JSON.stringify(form, null, 2)); 38 | }); 39 | 40 | return ( 41 | 42 | {selected && ( 43 |
44 | *Selected:* {selected} 45 |
46 | )} 47 | 48 | {!selected && ( 49 |
setSelected(event.selected.join(", "))} 57 | /> 58 | } 59 | /> 60 | )} 61 | 62 | 63 | 64 |
openModal()}> 68 | Open modal 69 | 70 | } 71 | /> 72 | 73 | {form && ( 74 |
75 | {"```\n" + form + "\n```"} 76 |
77 | )} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/example/example-messages/multi-conversations-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Divider, 6 | Input, 7 | Message, 8 | Modal, 9 | MultiSelectMenu, 10 | PheliaMessageProps, 11 | Section, 12 | Text 13 | } from "../../core"; 14 | 15 | export function MultiConversationsSelectMenuModal() { 16 | return ( 17 | 18 | 19 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export function MultiConversationsSelectMenuExample({ 30 | useModal, 31 | useState 32 | }: PheliaMessageProps) { 33 | const [form, setForm] = useState("form"); 34 | const [selected, setSelected] = useState("selected"); 35 | 36 | const openModal = useModal( 37 | "modal", 38 | MultiConversationsSelectMenuModal, 39 | form => { 40 | setForm(JSON.stringify(form, null, 2)); 41 | } 42 | ); 43 | 44 | return ( 45 | 46 | {selected && ( 47 |
48 | *Selected:* {selected} 49 |
50 | )} 51 | 52 | {!selected && ( 53 |
setSelected(event.selected.join(", "))} 61 | /> 62 | } 63 | /> 64 | )} 65 | 66 | 67 | 68 |
openModal()}> 72 | Open modal 73 | 74 | } 75 | /> 76 | 77 | {form && ( 78 |
79 | {"```\n" + form + "\n```"} 80 |
81 | )} 82 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /src/example/example-messages/multi-external-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Text, 6 | Input, 7 | Message, 8 | Modal, 9 | PheliaMessageProps, 10 | Section, 11 | Divider, 12 | Option, 13 | OptionGroup, 14 | MultiSelectMenu 15 | } from "../../core"; 16 | 17 | export function MultiExternalSelectMenuModal() { 18 | return ( 19 | 20 | 21 | [ 24 | 25 | 26 | 27 | ]} 28 | type="external" 29 | action="select-menu" 30 | placeholder="A placeholder" 31 | /> 32 | 33 | 34 | ); 35 | } 36 | 37 | export function MultiExternalSelectMenuExample({ 38 | useModal, 39 | useState 40 | }: PheliaMessageProps) { 41 | const [form, setForm] = useState("form"); 42 | const [selected, setSelected] = useState("selected"); 43 | 44 | const openModal = useModal("modal", MultiExternalSelectMenuModal, form => { 45 | setForm(JSON.stringify(form, null, 2)); 46 | }); 47 | 48 | return ( 49 | 50 | {selected && ( 51 |
52 | *Selected:* {selected} 53 |
54 | )} 55 | 56 | {!selected && ( 57 |
63 | This was loaded asynchronously 64 | 65 | ]} 66 | onSearchOptions={() => [ 67 | 70 | ]} 71 | type="external" 72 | action="external" 73 | placeholder="A placeholder" 74 | onSelect={event => setSelected(event.selected.join(", "))} 75 | /> 76 | } 77 | /> 78 | )} 79 | 80 | 81 | 82 |
openModal()}> 86 | Open modal 87 | 88 | } 89 | /> 90 | 91 | {form && ( 92 |
93 | {"```\n" + form + "\n```"} 94 |
95 | )} 96 | 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/example/example-messages/multi-static-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Actions, 5 | Button, 6 | Divider, 7 | Input, 8 | Message, 9 | Modal, 10 | Option, 11 | OptionGroup, 12 | PheliaMessageProps, 13 | Section, 14 | Text, 15 | MultiSelectMenu 16 | } from "../../core"; 17 | 18 | export function MultiStaticSelectMenuModal() { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | export function MultiStaticSelectMenuExample({ 43 | useModal, 44 | useState 45 | }: PheliaMessageProps) { 46 | const [form, setForm] = useState("form"); 47 | const [selected, setSelected] = useState("selected"); 48 | 49 | const openModal = useModal("modal", MultiStaticSelectMenuModal, form => { 50 | setForm(JSON.stringify(form, null, 2)); 51 | }); 52 | 53 | return ( 54 | 55 | {selected && ( 56 |
57 | *Selected:* {selected} 58 |
59 | )} 60 | 61 | {!selected && ( 62 |
setSelected(event.selected.join(", "))} 69 | > 70 | 71 | 72 | 75 | 76 | 79 | 80 | } 81 | /> 82 | )} 83 | 84 | 85 | 86 |
openModal()}> 90 | Open modal 91 | 92 | } 93 | /> 94 | 95 | {form && ( 96 |
97 | {"```\n" + form + "\n```"} 98 |
99 | )} 100 | 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/example/example-messages/multi-users-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Divider, 6 | Input, 7 | Message, 8 | Modal, 9 | MultiSelectMenu, 10 | PheliaMessageProps, 11 | Section, 12 | Text 13 | } from "../../core"; 14 | 15 | export function MultiUsersSelectMenuModal() { 16 | return ( 17 | 18 | 19 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export function MultiUsersSelectMenuExample({ 30 | useModal, 31 | useState 32 | }: PheliaMessageProps) { 33 | const [form, setForm] = useState("form"); 34 | const [selected, setSelected] = useState("selected"); 35 | 36 | const openModal = useModal("modal", MultiUsersSelectMenuModal, form => { 37 | setForm(JSON.stringify(form, null, 2)); 38 | }); 39 | 40 | return ( 41 | 42 | {selected && ( 43 |
44 | *Selected:* {selected} 45 |
46 | )} 47 | 48 | {!selected && ( 49 |
setSelected(event.selected.join(", "))} 57 | /> 58 | } 59 | /> 60 | )} 61 | 62 | 63 | 64 |
openModal()}> 68 | Open modal 69 | 70 | } 71 | /> 72 | 73 | {form && ( 74 |
75 | {"```\n" + form + "\n```"} 76 |
77 | )} 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/example/example-messages/overflow-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Message, 4 | OverflowMenu, 5 | Option, 6 | Section, 7 | Text, 8 | PheliaMessageProps 9 | } from "../../core"; 10 | 11 | export function OverflowMenuExample({ useState }: PheliaMessageProps) { 12 | const [selected, setSelected] = useState("selected"); 13 | 14 | const overflow = ( 15 | setSelected(event.selected)} 18 | > 19 | 20 | 21 | 24 | 25 | ); 26 | 27 | return ( 28 | 29 |
30 | {selected ? ( 31 | You selected *{selected}* 32 | ) : ( 33 | Click the menu option :point_right: 34 | )} 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/example/example-messages/radio-buttons.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Text, 6 | Input, 7 | Message, 8 | Modal, 9 | Option, 10 | PheliaMessageProps, 11 | Section, 12 | RadioButtons 13 | } from "../../core"; 14 | 15 | export function RadioButtonModal() { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | export function RadioButtonExample({ useModal, useState }: PheliaMessageProps) { 34 | const [form, setForm] = useState("form"); 35 | 36 | const openModal = useModal("modal", RadioButtonModal, form => { 37 | setForm(JSON.stringify(form, null, 2)); 38 | }); 39 | 40 | return ( 41 | 42 |
openModal()}> 46 | Open modal 47 | 48 | } 49 | /> 50 | 51 | {form && ( 52 |
53 | {"```\n" + form + "\n```"} 54 |
55 | )} 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/example/example-messages/random-image.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Actions, 5 | Button, 6 | Confirm, 7 | Divider, 8 | ImageBlock, 9 | PheliaMessageProps, 10 | Text, 11 | Message 12 | } from "../../core"; 13 | 14 | const imageUrls = [ 15 | "https://cdn.pixabay.com/photo/2015/06/08/15/02/pug-801826__480.jpg", 16 | "https://cdn.pixabay.com/photo/2015/03/26/09/54/pug-690566__480.jpg", 17 | "https://cdn.pixabay.com/photo/2018/03/31/06/31/dog-3277416__480.jpg", 18 | "https://cdn.pixabay.com/photo/2016/02/26/16/32/dog-1224267__480.jpg" 19 | ]; 20 | 21 | function randomImage(): string { 22 | const index = Math.floor(Math.random() * imageUrls.length); 23 | return imageUrls[index]; 24 | } 25 | 26 | export function RandomImage({ useState }: PheliaMessageProps) { 27 | const [imageUrl, setImageUrl] = useState("imageUrl", randomImage()); 28 | 29 | return ( 30 | 31 | 37 | 38 | 39 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/example/example-messages/static-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Actions, 5 | Button, 6 | Divider, 7 | Input, 8 | Message, 9 | Modal, 10 | Option, 11 | OptionGroup, 12 | PheliaMessageProps, 13 | Section, 14 | SelectMenu, 15 | Text 16 | } from "../../core"; 17 | 18 | export function StaticSelectMenuModal() { 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | export function StaticSelectMenuExample({ 43 | useModal, 44 | useState 45 | }: PheliaMessageProps) { 46 | const [form, setForm] = useState("form"); 47 | const [selected, setSelected] = useState("selected"); 48 | 49 | const openModal = useModal("modal", StaticSelectMenuModal, form => { 50 | setForm(JSON.stringify(form, null, 2)); 51 | }); 52 | 53 | return ( 54 | 55 | {selected && ( 56 |
57 | *Selected:* {selected} 58 |
59 | )} 60 | 61 | {!selected && ( 62 | <> 63 | 64 | setSelected(event.selected)} 68 | > 69 | 70 | 71 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | setSelected(event.selected)} 84 | > 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 96 | 97 | 98 | 99 | 100 | 101 | )} 102 | 103 | 104 | 105 |
openModal()}> 109 | Open modal 110 | 111 | } 112 | /> 113 | 114 | {form && ( 115 |
116 | {"```\n" + form + "\n```"} 117 |
118 | )} 119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /src/example/example-messages/users-select-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Button, 5 | Text, 6 | Input, 7 | Message, 8 | Modal, 9 | PheliaMessageProps, 10 | Section, 11 | Divider, 12 | Actions, 13 | SelectMenu 14 | } from "../../core"; 15 | 16 | export function UsersSelectMenuModal() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | export function UsersSelectMenuExample({ 27 | useModal, 28 | useState 29 | }: PheliaMessageProps) { 30 | const [form, setForm] = useState("form"); 31 | const [selected, setSelected] = useState("selected"); 32 | 33 | const openModal = useModal("modal", UsersSelectMenuModal, form => { 34 | setForm(JSON.stringify(form, null, 2)); 35 | }); 36 | 37 | return ( 38 | 39 | {selected && ( 40 |
41 | *Selected:* {selected} 42 |
43 | )} 44 | 45 | {!selected && ( 46 | <> 47 | 48 | setSelected(event.selected)} 53 | /> 54 | 55 | 56 | )} 57 | 58 | 59 | 60 |
openModal()}> 64 | Open modal 65 | 66 | } 67 | /> 68 | 69 | {form && ( 70 |
71 | {"```\n" + form + "\n```"} 72 |
73 | )} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/example/server.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import express from "express"; 3 | import { createEventAdapter } from "@slack/events-api"; 4 | 5 | import Phelia from "../core"; 6 | import { 7 | BirthdayPicker, 8 | ChannelsSelectMenuExample, 9 | ChannelsSelectMenuModal, 10 | ConversationsSelectMenuExample, 11 | ConversationsSelectMenuModal, 12 | Counter, 13 | ExternalSelectMenuExample, 14 | ExternalSelectMenuModal, 15 | Greeter, 16 | ModalExample, 17 | MultiChannelsSelectMenuExample, 18 | MultiChannelsSelectMenuModal, 19 | MultiConversationsSelectMenuExample, 20 | MultiConversationsSelectMenuModal, 21 | MultiExternalSelectMenuExample, 22 | MultiExternalSelectMenuModal, 23 | MultiStaticSelectMenuExample, 24 | MultiStaticSelectMenuModal, 25 | MultiUsersSelectMenuExample, 26 | MultiUsersSelectMenuModal, 27 | MyModal, 28 | OverflowMenuExample, 29 | RadioButtonExample, 30 | RadioButtonModal, 31 | RandomImage, 32 | StaticSelectMenuExample, 33 | StaticSelectMenuModal, 34 | UsersSelectMenuExample, 35 | UsersSelectMenuModal, 36 | HomeApp, 37 | } from "./example-messages"; 38 | 39 | dotenv.config(); 40 | 41 | const app = express(); 42 | const port = 3000; 43 | 44 | const client = new Phelia(process.env.SLACK_TOKEN); 45 | 46 | client.registerComponents([ 47 | BirthdayPicker, 48 | Counter, 49 | Greeter, 50 | ModalExample, 51 | MyModal, 52 | RandomImage, 53 | OverflowMenuExample, 54 | RadioButtonModal, 55 | RadioButtonExample, 56 | StaticSelectMenuExample, 57 | StaticSelectMenuModal, 58 | UsersSelectMenuExample, 59 | UsersSelectMenuModal, 60 | ConversationsSelectMenuExample, 61 | ConversationsSelectMenuModal, 62 | ChannelsSelectMenuModal, 63 | ChannelsSelectMenuExample, 64 | ExternalSelectMenuExample, 65 | ExternalSelectMenuModal, 66 | MultiStaticSelectMenuExample, 67 | MultiStaticSelectMenuModal, 68 | MultiExternalSelectMenuExample, 69 | MultiExternalSelectMenuModal, 70 | MultiUsersSelectMenuExample, 71 | MultiUsersSelectMenuModal, 72 | MultiChannelsSelectMenuExample, 73 | MultiChannelsSelectMenuModal, 74 | MultiConversationsSelectMenuExample, 75 | MultiConversationsSelectMenuModal, 76 | ]); 77 | 78 | // Register the interaction webhook 79 | app.post( 80 | "/interactions", 81 | client.messageHandler(process.env.SLACK_SIGNING_SECRET) 82 | ); 83 | 84 | // Register your Home App 85 | const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET); 86 | 87 | slackEvents.on("app_home_opened", client.appHomeHandler(HomeApp)); 88 | 89 | app.use("/events", slackEvents.requestListener()); 90 | 91 | (async () => { 92 | const key = await client.postMessage(ModalExample, "@max", { name: "Max" }); 93 | 94 | await client.updateMessage(key, { name: "me but laters" }); 95 | })(); 96 | 97 | app.listen(port, () => 98 | console.log(`Example app listening at http://localhost:${port}`) 99 | ); 100 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core"; 2 | 3 | import Phelia from "./core"; 4 | 5 | export default Phelia; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "target": "es6", 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "baseUrl": "." 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules/**/*"] 15 | } 16 | --------------------------------------------------------------------------------