├── images ├── tasks-shortcut.gif ├── tasks-home-screen.png └── tasks-message-shortcut.gif ├── .env.sample ├── user-interface ├── messages │ ├── index.js │ ├── task-reminder.js │ └── __tests__ │ │ └── task-reminder.test.js ├── app-home │ ├── index.js │ ├── completed-tasks-view.js │ ├── __tests__ │ │ ├── open-tasks-view.test.js │ │ └── completed-tasks-view.test.js │ └── open-tasks-view.js ├── index.js └── modals │ ├── index.js │ ├── task-created.js │ ├── task-creation-error.js │ ├── __tests__ │ ├── task-created.test.js │ ├── task-creation-error.test.js │ └── new-task.test.js │ └── new-task.js ├── utilities ├── index.js ├── complete-tasks.js └── reload-app-home.js ├── listeners ├── views │ ├── index.js │ ├── __tests__ │ │ ├── __utils__ │ │ │ └── view-test-util-funcs.js │ │ ├── new-task-modal.test.js │ │ └── __fixtures__ │ │ │ └── view-fixtures.js │ └── new-task-modal.js ├── events │ ├── index.js │ ├── __tests__ │ │ ├── __fixtures__ │ │ │ └── event-fixtures.js │ │ ├── __utils__ │ │ │ └── event-test-util-funcs.js │ │ └── app_home_opened.test.js │ └── app_home_opened.js ├── shortcuts │ ├── index.js │ ├── global-new-task.js │ ├── message-new-task.js │ └── __tests__ │ │ ├── global-new-task.test.js │ │ ├── message-new-task.test.js │ │ ├── __fixtures__ │ │ └── shortcut-fixtures.js │ │ └── __utils__ │ │ └── shortcut-test-util-funcs.js ├── index.js └── actions │ ├── block_reopen-task.js │ ├── block_app-home-nav-open.js │ ├── block_app-home-nav-completed.js │ ├── block_app-home-nav-create-a-task.js │ ├── block_open_task_list_home.js │ ├── __tests__ │ ├── block_reopen-task.test.js │ ├── block_app-home-nav-open.test.js │ ├── block_app-home-nav-completed.test.js │ ├── block_open_task_list_home.test.js │ ├── block_app-home-nav-create-a-task.test.js │ ├── block_button-mark-as-done.test.js │ ├── __utils__ │ │ └── action-test-util-funcs.js │ └── __fixtures__ │ │ └── action-fixtures.js │ ├── block_button-mark-as-done.js │ └── index.js ├── .eslintrc.js ├── config └── config.json ├── migrations ├── 20210621100111-add-due-date-to-tasks.js ├── 20210629100136-add-schedule-message-id-to-tasks.js ├── 20210614140421-user-to-task-key.js ├── 20210614114545-create-task.js ├── 20210614115427-create-user.js └── 20210707155832-assign-user-to-task.js ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── node-ci-lint.yml │ ├── node-ci-build.yml │ └── codeql-analysis.yml ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE │ ├── FEATURE_REQUEST.yaml │ └── BUG_REPORT.yaml └── CONTRIBUTING.md ├── .jest ├── setup.js ├── mock-api-method-funcs.js └── global-helper-funcs.js ├── jest.config.js ├── models ├── task.js ├── user.js └── index.js ├── manifest.yml ├── LICENSE ├── README.md ├── package.json ├── app.js ├── docs ├── setup.md └── structure.md └── .gitignore /images/tasks-shortcut.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/bolt-js-tasks-app/HEAD/images/tasks-shortcut.gif -------------------------------------------------------------------------------- /images/tasks-home-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/bolt-js-tasks-app/HEAD/images/tasks-home-screen.png -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | SLACK_BOT_TOKEN=xoxb-123456-abcdefghijkl 2 | SLACK_APP_TOKEN=xapp-123456-abcdefghijkl 3 | DB_URI=sqlite:./database.sqlite3 -------------------------------------------------------------------------------- /images/tasks-message-shortcut.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slack-samples/bolt-js-tasks-app/HEAD/images/tasks-message-shortcut.gif -------------------------------------------------------------------------------- /user-interface/messages/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | module.exports = { 4 | taskReminder: require('./task-reminder'), 5 | }; 6 | -------------------------------------------------------------------------------- /utilities/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | module.exports = { 4 | reloadAppHome: require('./reload-app-home'), 5 | completeTasks: require('./complete-tasks'), 6 | }; 7 | -------------------------------------------------------------------------------- /listeners/views/index.js: -------------------------------------------------------------------------------- 1 | const { newTaskModalCallback } = require('./new-task-modal'); 2 | 3 | module.exports.register = (app) => { 4 | app.view('new-task-modal', newTaskModalCallback); 5 | }; 6 | -------------------------------------------------------------------------------- /listeners/events/index.js: -------------------------------------------------------------------------------- 1 | const { appHomeOpenedCallback } = require('./app_home_opened'); 2 | 3 | module.exports.register = (app) => { 4 | app.event('app_home_opened', appHomeOpenedCallback); 5 | }; 6 | -------------------------------------------------------------------------------- /user-interface/app-home/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | module.exports = { 4 | openTasksView: require('./open-tasks-view'), 5 | completedTasksView: require('./completed-tasks-view'), 6 | }; 7 | -------------------------------------------------------------------------------- /user-interface/index.js: -------------------------------------------------------------------------------- 1 | const modals = require('./modals'); 2 | const appHome = require('./app-home'); 3 | const messages = require('./messages'); 4 | 5 | exports.modals = modals; 6 | exports.appHome = appHome; 7 | exports.messages = messages; 8 | -------------------------------------------------------------------------------- /user-interface/modals/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | module.exports = { 4 | newTask: require('./new-task'), 5 | taskCreated: require('./task-created'), 6 | taskCreationError: require('./task-creation-error'), 7 | }; 8 | -------------------------------------------------------------------------------- /user-interface/modals/task-created.js: -------------------------------------------------------------------------------- 1 | const { Modal, Blocks } = require('slack-block-builder'); 2 | 3 | module.exports = (taskTitle) => Modal({ title: 'Task created', callbackId: 'task-created-modal' }) 4 | .blocks( 5 | Blocks.Section({ 6 | text: `${taskTitle} created`, 7 | }), 8 | ).buildToJSON(); 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | 'jest/globals': true, 4 | browser: true, 5 | commonjs: true, 6 | es2021: true, 7 | }, 8 | plugins: ['jest'], 9 | extends: ['airbnb-base', 'eslint:recommended', 'prettier'], 10 | parserOptions: { 11 | ecmaVersion: 12, 12 | }, 13 | rules: {}, 14 | }; 15 | -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "development": { 3 | "storage": "./database.sqlite3", 4 | "dialect": "sqlite" 5 | }, 6 | "test": { 7 | "storage": "./database.sqlite3", 8 | "dialect": "sqlite" 9 | }, 10 | "production": { 11 | "storage": "./database.sqlite3", 12 | "dialect": "sqlite" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /listeners/shortcuts/index.js: -------------------------------------------------------------------------------- 1 | const { messageNewTaskCallback } = require('./message-new-task'); 2 | const { globalNewTaskCallback } = require('./global-new-task'); 3 | 4 | module.exports.register = (app) => { 5 | app.shortcut('message_new_task', messageNewTaskCallback); 6 | app.shortcut('global_new_task', globalNewTaskCallback); 7 | }; -------------------------------------------------------------------------------- /user-interface/modals/task-creation-error.js: -------------------------------------------------------------------------------- 1 | const { Modal, Blocks } = require('slack-block-builder'); 2 | 3 | module.exports = (taskTitle) => Modal({ title: 'Something went wrong', callbackId: 'task-creation-error-modal' }) 4 | .blocks( 5 | Blocks.Section({ 6 | text: `We couldn't create ${taskTitle}. Sorry!`, 7 | }), 8 | ).buildToJSON(); 9 | -------------------------------------------------------------------------------- /migrations/20210621100111-add-due-date-to-tasks.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => queryInterface.addColumn( 3 | 'Tasks', 4 | 'dueDate', 5 | { 6 | type: Sequelize.DATE, 7 | allowNull: true, 8 | }, 9 | ), 10 | 11 | down: async (queryInterface) => queryInterface.removeColumn( 12 | 'Tasks', 13 | 'dueDate', 14 | ), 15 | }; 16 | -------------------------------------------------------------------------------- /listeners/index.js: -------------------------------------------------------------------------------- 1 | const shortcutsListener = require('./shortcuts'); 2 | const viewsListener = require('./views'); 3 | const eventsListener = require('./events'); 4 | const actionsListener = require('./actions'); 5 | 6 | module.exports.registerListeners = (app) => { 7 | shortcutsListener.register(app); 8 | viewsListener.register(app); 9 | eventsListener.register(app); 10 | actionsListener.register(app); 11 | }; 12 | -------------------------------------------------------------------------------- /migrations/20210629100136-add-schedule-message-id-to-tasks.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => queryInterface.addColumn( 3 | 'Tasks', 4 | 'scheduledMessageId', 5 | { 6 | type: Sequelize.STRING, 7 | allowNull: true, 8 | }, 9 | ), 10 | 11 | down: async (queryInterface) => queryInterface.removeColumn( 12 | 'Tasks', 13 | 'scheduledMessageId', 14 | ), 15 | }; 16 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | Describe the goal of this PR. Mention any related Issue numbers. 4 | 5 | ### Requirements (place an `x` in each `[ ]`) 6 | 7 | * [ ] I've read and understood the [Contributing Guidelines](https://github.com/slackapi/tasks-app/blob/main/.github/contributing.md) and have done my best effort to follow them. 8 | * [ ] I've read and agree to the [Code of Conduct](https://slackhq.github.io/code-of-conduct). -------------------------------------------------------------------------------- /listeners/actions/block_reopen-task.js: -------------------------------------------------------------------------------- 1 | const { Task } = require('../../models'); 2 | const { reloadAppHome } = require('../../utilities'); 3 | 4 | const reopenTaskCallback = async ({ ack, action, client, body }) => { 5 | await ack(); 6 | Task.update({ status: 'OPEN' }, { where: { id: action.value } }); 7 | await reloadAppHome(client, body.user.id, body.team.id, 'completed'); 8 | }; 9 | 10 | module.exports = { 11 | reopenTaskCallback, 12 | }; 13 | -------------------------------------------------------------------------------- /listeners/actions/block_app-home-nav-open.js: -------------------------------------------------------------------------------- 1 | const reloadAppHome = require('../../utilities/reload-app-home'); 2 | 3 | const appHomeNavOpenCallback = async ({ body, ack, client }) => { 4 | try { 5 | await ack(); 6 | await reloadAppHome(client, body.user.id, body.team.id, 'open'); 7 | } catch (error) { 8 | // eslint-disable-next-line no-console 9 | console.error(error); 10 | } 11 | }; 12 | 13 | module.exports = { 14 | appHomeNavOpenCallback, 15 | }; 16 | -------------------------------------------------------------------------------- /listeners/actions/block_app-home-nav-completed.js: -------------------------------------------------------------------------------- 1 | const reloadAppHome = require('../../utilities/reload-app-home'); 2 | 3 | const appHomeNavCompletedCallback = async ({ body, ack, client }) => { 4 | try { 5 | await ack(); 6 | await reloadAppHome(client, body.user.id, body.team.id, 'completed'); 7 | } catch (error) { 8 | // eslint-disable-next-line no-console 9 | console.error(error); 10 | } 11 | }; 12 | 13 | module.exports = { appHomeNavCompletedCallback }; 14 | -------------------------------------------------------------------------------- /migrations/20210614140421-user-to-task-key.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => queryInterface.addColumn( 3 | 'Tasks', 4 | 'UserId', 5 | { 6 | type: Sequelize.INTEGER, 7 | references: { 8 | model: 'Users', 9 | key: 'id', 10 | }, 11 | onUpdate: 'CASCADE', 12 | onDelete: 'SET NULL', 13 | }, 14 | ), 15 | 16 | down: async (queryInterface) => queryInterface.removeColumn( 17 | 'Tasks', 18 | 'UserID', 19 | ), 20 | }; 21 | -------------------------------------------------------------------------------- /listeners/actions/block_app-home-nav-create-a-task.js: -------------------------------------------------------------------------------- 1 | const { modals } = require('../../user-interface'); 2 | 3 | const appHomeNavCreateATaskCallback = async ({ body, ack, client }) => { 4 | try { 5 | await ack(); 6 | await client.views.open({ 7 | trigger_id: body.trigger_id, 8 | view: modals.newTask(null, body.user.id), 9 | }); 10 | } catch (error) { 11 | // eslint-disable-next-line no-console 12 | console.error(error); 13 | } 14 | }; 15 | 16 | module.exports = { 17 | appHomeNavCreateATaskCallback, 18 | }; 19 | -------------------------------------------------------------------------------- /listeners/shortcuts/global-new-task.js: -------------------------------------------------------------------------------- 1 | const { modals } = require('../../user-interface'); 2 | 3 | // Expose callback function for testing 4 | const globalNewTaskCallback = async ({ shortcut, ack, client }) => { 5 | try { 6 | await ack(); 7 | await client.views.open({ 8 | trigger_id: shortcut.trigger_id, 9 | view: modals.newTask(null, shortcut.user.id), 10 | }); 11 | } catch (error) { 12 | // eslint-disable-next-line no-console 13 | console.error(error); 14 | } 15 | }; 16 | 17 | module.exports = { globalNewTaskCallback }; 18 | -------------------------------------------------------------------------------- /listeners/shortcuts/message-new-task.js: -------------------------------------------------------------------------------- 1 | const { modals } = require('../../user-interface'); 2 | 3 | // Expose callback function for testing 4 | const messageNewTaskCallback = async ({ shortcut, ack, client }) => { 5 | try { 6 | await ack(); 7 | await client.views.open({ 8 | trigger_id: shortcut.trigger_id, 9 | view: modals.newTask(shortcut.message.text, shortcut.user.id), 10 | }); 11 | } catch (error) { 12 | // eslint-disable-next-line no-console 13 | console.error(error); 14 | } 15 | }; 16 | 17 | module.exports = { messageNewTaskCallback }; 18 | -------------------------------------------------------------------------------- /.github/workflows/node-ci-lint.yml: -------------------------------------------------------------------------------- 1 | name: Node Lint 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [15.x] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm ci 20 | - run: npm run build --if-present 21 | - run: npm run lint -------------------------------------------------------------------------------- /.github/workflows/node-ci-build.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [12.x, 14.x, 15.x] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm ci 20 | - run: npm run build --if-present 21 | - run: npm test -------------------------------------------------------------------------------- /user-interface/messages/task-reminder.js: -------------------------------------------------------------------------------- 1 | const { Message, Section, Button } = require('slack-block-builder'); 2 | 3 | module.exports = (postAt, channel, taskTitle, dueDate, taskID) => Message({ 4 | channel, 5 | postAt, 6 | text: `You asked me to remind you about "${taskTitle}".`, 7 | }).blocks( 8 | Section({ text: `:wave: You asked me to remind you about "*${taskTitle}*".` }) 9 | .accessory(Button({ text: 'Mark as done', value: `task-${taskID}`, actionId: 'button-mark-as-done' })), 10 | Section().fields(['*Task title*', '*Due date*', taskTitle, dueDate]), 11 | ).buildToObject(); 12 | -------------------------------------------------------------------------------- /user-interface/modals/__tests__/task-created.test.js: -------------------------------------------------------------------------------- 1 | const { taskCreated } = require('../index'); 2 | 3 | test('Returns blocks for the task created modal', () => { 4 | const expected = { 5 | title: { 6 | type: 'plain_text', 7 | text: 'Task created', 8 | }, 9 | callback_id: 'task-created-modal', 10 | blocks: [ 11 | { 12 | text: { 13 | type: 'mrkdwn', 14 | text: 'Task Title created', 15 | }, 16 | type: 'section', 17 | }, 18 | ], 19 | type: 'modal', 20 | }; 21 | expect(taskCreated('Task Title')).toBe(JSON.stringify(expected)); 22 | }); 23 | -------------------------------------------------------------------------------- /user-interface/modals/__tests__/task-creation-error.test.js: -------------------------------------------------------------------------------- 1 | const { taskCreationError } = require('../index'); 2 | 3 | test('Returns blocks for the task creation error modal', () => { 4 | const expected = { 5 | title: { 6 | type: 'plain_text', 7 | text: 'Something went wrong', 8 | }, 9 | callback_id: 'task-creation-error-modal', 10 | blocks: [ 11 | { 12 | text: { 13 | type: 'mrkdwn', 14 | text: "We couldn't create Task Title. Sorry!", 15 | }, 16 | type: 'section', 17 | }, 18 | ], 19 | type: 'modal', 20 | }; 21 | expect(taskCreationError('Task Title')).toBe(JSON.stringify(expected)); 22 | }); 23 | -------------------------------------------------------------------------------- /listeners/actions/block_open_task_list_home.js: -------------------------------------------------------------------------------- 1 | const { reloadAppHome, completeTasks } = require('../../utilities'); 2 | 3 | const openTaskCheckboxClickedCallback = async ({ 4 | ack, 5 | action, 6 | client, 7 | body, 8 | }) => { 9 | await ack(); 10 | if (action.selected_options.length > 0) { 11 | const tasksToUpdate = action.selected_options.map( 12 | (option) => option.value.split('-')[2], 13 | ); 14 | await completeTasks(tasksToUpdate, body.user.id, client); 15 | } 16 | await reloadAppHome(client, body.user.id, body.team.id); 17 | }; 18 | 19 | // TODO: reformat action_ids to all be snake cased 20 | module.exports = { 21 | openTaskCheckboxClickedCallback, 22 | }; 23 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/block_reopen-task.test.js: -------------------------------------------------------------------------------- 1 | const { buttonPressActionPayload } = require('./__fixtures__/action-fixtures'); 2 | 3 | const { 4 | testAction, 5 | testActionError, 6 | } = require('./__utils__/action-test-util-funcs'); 7 | const { reopenTaskCallback } = require('../block_reopen-task'); 8 | 9 | describe('App home nav completed action callback function test ', () => { 10 | it('Acknowledges the action and reloads the app home', async () => { 11 | await testAction(buttonPressActionPayload, reopenTaskCallback); 12 | }); 13 | it('Logs an error when the the new view fails to be published', async () => { 14 | await testActionError(buttonPressActionPayload, reopenTaskCallback); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /listeners/events/__tests__/__fixtures__/event-fixtures.js: -------------------------------------------------------------------------------- 1 | // A new user of the app (who has never set the App Home) will not have a 'view' property in their event payload 2 | const mockAppHomeOpenedEventNewUser = { 3 | type: 'app_home_opened', 4 | user: 'U061F7AUR', 5 | channel: 'D0LAN2Q65', 6 | event_ts: '1515449522000016', 7 | tab: 'home', 8 | }; 9 | 10 | // An existing user's home page has been opened. It will have the view property 11 | const mockAppHomeOpenedEventExistingUser = { 12 | ...mockAppHomeOpenedEventNewUser, 13 | view: { 14 | team_id: 'T21312902', 15 | type: 'home', 16 | private_metadata: 'open', 17 | callback_id: '', 18 | }, 19 | }; 20 | 21 | module.exports = { 22 | mockAppHomeOpenedEventNewUser, 23 | mockAppHomeOpenedEventExistingUser, 24 | }; 25 | -------------------------------------------------------------------------------- /listeners/shortcuts/__tests__/global-new-task.test.js: -------------------------------------------------------------------------------- 1 | const { globalNewTaskCallback } = require('../global-new-task'); 2 | const { 3 | mockGlobalShortcutPayloadData, 4 | } = require('./__fixtures__/shortcut-fixtures'); 5 | const { 6 | testShortcutError, 7 | testShortcut, 8 | } = require('./__utils__/shortcut-test-util-funcs'); 9 | 10 | describe('Global shortcut callback function test ', () => { 11 | it('handles the global shortcut event and opens the newTask modal', async () => { 12 | await testShortcut(mockGlobalShortcutPayloadData, globalNewTaskCallback); 13 | }); 14 | 15 | it("logs an error if the modal can't be opened", async () => { 16 | await testShortcutError( 17 | mockGlobalShortcutPayloadData, 18 | globalNewTaskCallback, 19 | ); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /migrations/20210614114545-create-task.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('Tasks', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | title: { 11 | type: Sequelize.STRING, 12 | }, 13 | status: { 14 | type: Sequelize.ENUM('OPEN', 'CLOSED'), 15 | }, 16 | createdAt: { 17 | allowNull: false, 18 | type: Sequelize.DATE, 19 | }, 20 | updatedAt: { 21 | allowNull: false, 22 | type: Sequelize.DATE, 23 | }, 24 | }); 25 | }, 26 | down: async (queryInterface) => { 27 | await queryInterface.dropTable('Tasks'); 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /listeners/shortcuts/__tests__/message-new-task.test.js: -------------------------------------------------------------------------------- 1 | const { messageNewTaskCallback } = require('../message-new-task'); 2 | const { 3 | mockMessageShortcutPayloadData, 4 | } = require('./__fixtures__/shortcut-fixtures'); 5 | 6 | const { 7 | testShortcut, 8 | testShortcutError, 9 | } = require('./__utils__/shortcut-test-util-funcs'); 10 | 11 | describe('Message shortcut callback function test ', () => { 12 | it('handles the message shortcut event and opens the newTask modal', async () => { 13 | await testShortcut(mockMessageShortcutPayloadData, messageNewTaskCallback); 14 | }); 15 | 16 | it("logs an error if the modal can't be opened", async () => { 17 | await testShortcutError( 18 | mockMessageShortcutPayloadData, 19 | messageNewTaskCallback, 20 | ); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.jest/setup.js: -------------------------------------------------------------------------------- 1 | const helperFuncs = require('./global-helper-funcs'); 2 | const mockApiMethodFuncs = require('./mock-api-method-funcs'); 3 | 4 | // Replace every instance of console.log and console.error with a mock function. This way, we can keep track of the number of times they are called 5 | // and replace their functionality with a simple mock (that does nothing) so that we don't actually log anything to the console during testing. 6 | // TODO: Update this when we find a better way to do errors (ideally we should just be raising an exception here and logging elsewhere). 7 | global.console = { 8 | log: jest.fn(), 9 | // Keep native behaviour for other methods, use those to print out things in your own tests 10 | error: console.error, 11 | warn: console.warn, 12 | info: console.info, 13 | debug: console.debug, 14 | }; 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | module.exports = { 7 | // Automatically clear mock calls and instances between every test 8 | clearMocks: true, 9 | 10 | // Indicates whether the coverage information should be collected while executing the test 11 | collectCoverage: true, 12 | 13 | // The directory where Jest should output its coverage files 14 | coverageDirectory: 'coverage', 15 | 16 | // Indicates which provider should be used to instrument code for coverage 17 | coverageProvider: 'v8', 18 | 19 | // Ignores the utility functions folder 20 | testPathIgnorePatterns: ['/__fixtures__/', '/__utils__/'], 21 | 22 | // Global setup configuration 23 | setupFilesAfterEnv: ['/.jest/setup.js'], 24 | }; 25 | -------------------------------------------------------------------------------- /listeners/actions/block_button-mark-as-done.js: -------------------------------------------------------------------------------- 1 | const { completeTasks, reloadAppHome } = require('../../utilities'); 2 | 3 | const buttonMarkAsDoneCallback = async ({ ack, action, client, body }) => { 4 | try { 5 | await ack(); 6 | const taskID = action.value.split('-')[1]; 7 | await completeTasks([taskID], body.user.id, client); 8 | await client.chat.update({ 9 | channel: body.container.channel_id, 10 | ts: body.container.message_ts, 11 | text: `~${body.message.text}~`, 12 | blocks: [], // Remove all the existing blocks, just leaving the text above. 13 | }); 14 | await reloadAppHome(client, body.user.id, body.team.id, 'completed'); 15 | } catch (error) { 16 | // eslint-disable-next-line no-console 17 | console.error(error); 18 | } 19 | }; 20 | 21 | module.exports = { 22 | buttonMarkAsDoneCallback, 23 | }; 24 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/block_app-home-nav-open.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | appHomeBlockChecklistSelectionActionPayload, 3 | } = require('./__fixtures__/action-fixtures'); 4 | 5 | const { 6 | testAction, 7 | testActionError, 8 | } = require('./__utils__/action-test-util-funcs'); 9 | const { appHomeNavOpenCallback } = require('../block_app-home-nav-open'); 10 | 11 | describe('App home nav open action callback function test ', () => { 12 | it('Acknowledges the action and reloads the app home', async () => { 13 | await testAction( 14 | appHomeBlockChecklistSelectionActionPayload, 15 | appHomeNavOpenCallback, 16 | ); 17 | }); 18 | it('Logs an error when the the new view fails to be published', async () => { 19 | await testActionError( 20 | appHomeBlockChecklistSelectionActionPayload, 21 | appHomeNavOpenCallback, 22 | ); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/block_app-home-nav-completed.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | appHomeBlockChecklistSelectionActionPayload, 3 | } = require('./__fixtures__/action-fixtures'); 4 | 5 | const { 6 | testAction, 7 | testActionError, 8 | } = require('./__utils__/action-test-util-funcs'); 9 | const { 10 | appHomeNavCompletedCallback, 11 | } = require('../block_app-home-nav-completed'); 12 | 13 | describe('App home nav completed action callback function test ', () => { 14 | it('Acknowledges the action and reloads the app home', async () => { 15 | await testAction( 16 | appHomeBlockChecklistSelectionActionPayload, 17 | appHomeNavCompletedCallback, 18 | ); 19 | }); 20 | it('Logs an error when the the new view fails to be published', async () => { 21 | await testActionError( 22 | appHomeBlockChecklistSelectionActionPayload, 23 | appHomeNavCompletedCallback, 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/block_open_task_list_home.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | appHomeBlockChecklistSelectionActionPayload, 3 | } = require('./__fixtures__/action-fixtures'); 4 | 5 | const { 6 | testAction, 7 | testActionError, 8 | } = require('./__utils__/action-test-util-funcs'); 9 | const { 10 | openTaskCheckboxClickedCallback, 11 | } = require('../block_open_task_list_home'); 12 | 13 | describe('App home nav completed action callback function test ', () => { 14 | it('Acknowledges the action and reloads the app home', async () => { 15 | await testAction( 16 | appHomeBlockChecklistSelectionActionPayload, 17 | openTaskCheckboxClickedCallback, 18 | ); 19 | }); 20 | it('Logs an error when the the new view fails to be published', async () => { 21 | await testActionError( 22 | appHomeBlockChecklistSelectionActionPayload, 23 | openTaskCheckboxClickedCallback, 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /listeners/events/app_home_opened.js: -------------------------------------------------------------------------------- 1 | const { reloadAppHome } = require('../../utilities'); 2 | 3 | const appHomeOpenedCallback = async ({ client, event, body }) => { 4 | if (event.tab !== 'home') { 5 | // Ignore the `app_home_opened` event for everything 6 | // except home as we don't support a conversational UI 7 | return; 8 | } 9 | try { 10 | if (event.view) { 11 | await reloadAppHome( 12 | client, 13 | event.user, 14 | body.team_id, 15 | event.view.private_metadata, 16 | ); 17 | return; 18 | } 19 | 20 | // For new users where we've never set the App Home, 21 | // the App Home event won't send a `view` property 22 | await reloadAppHome(client, event.user, body.team_id, ''); 23 | } catch (error) { 24 | // eslint-disable-next-line no-console 25 | console.error(error); 26 | } 27 | }; 28 | 29 | module.exports = { appHomeOpenedCallback }; 30 | -------------------------------------------------------------------------------- /migrations/20210614115427-create-user.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('Users', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | slackUserID: { 11 | type: Sequelize.STRING, 12 | allowNull: false, 13 | }, 14 | slackOrganizationID: { 15 | type: Sequelize.STRING, 16 | }, 17 | slackWorkspaceID: { 18 | type: Sequelize.STRING, 19 | allowNull: false, 20 | }, 21 | createdAt: { 22 | allowNull: false, 23 | type: Sequelize.DATE, 24 | }, 25 | updatedAt: { 26 | allowNull: false, 27 | type: Sequelize.DATE, 28 | }, 29 | }); 30 | }, 31 | down: async (queryInterface) => { 32 | await queryInterface.dropTable('Users'); 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /models/task.js: -------------------------------------------------------------------------------- 1 | const { 2 | Model, 3 | } = require('sequelize'); 4 | 5 | module.exports = (sequelize, DataTypes) => { 6 | class Task extends Model { 7 | /** 8 | * Helper method for defining associations. 9 | * This method is not a part of Sequelize lifecycle. 10 | * The `models/index` file will call this method automatically. 11 | */ 12 | static associate(models) { 13 | Task.belongsTo(models.User, { as: 'creator' }); 14 | Task.belongsTo(models.User, { as: 'currentAssignee' }); 15 | } 16 | } 17 | Task.init({ 18 | // Model attributes are defined here 19 | title: DataTypes.STRING, 20 | status: { 21 | type: DataTypes.ENUM, 22 | values: ['OPEN', 'CLOSED'], 23 | defaultValue: 'OPEN', 24 | }, 25 | dueDate: DataTypes.DATE, 26 | scheduledMessageId: DataTypes.STRING, 27 | }, 28 | { 29 | sequelize, 30 | modelName: 'Task', 31 | }); 32 | return Task; 33 | }; 34 | -------------------------------------------------------------------------------- /models/user.js: -------------------------------------------------------------------------------- 1 | const { 2 | Model, 3 | } = require('sequelize'); 4 | 5 | module.exports = (sequelize, DataTypes) => { 6 | class User extends Model { 7 | /** 8 | * Helper method for defining associations. 9 | * This method is not a part of Sequelize lifecycle. 10 | * The `models/index` file will call this method automatically. 11 | */ 12 | static associate(models) { 13 | User.hasMany(models.Task, { as: 'createdTasks', foreignKey: 'creatorId' }); 14 | User.hasMany(models.Task, { as: 'assignedTasks', foreignKey: 'currentAssigneeId' }); 15 | } 16 | } 17 | User.init({ 18 | slackUserID: { 19 | type: DataTypes.STRING, 20 | allowNull: false, 21 | }, 22 | slackOrganizationID: { 23 | type: DataTypes.STRING, 24 | }, 25 | slackWorkspaceID: { 26 | type: DataTypes.STRING, 27 | allowNull: false, 28 | }, 29 | }, { 30 | sequelize, 31 | modelName: 'User', 32 | }); 33 | return User; 34 | }; 35 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | Diversity and inclusion make our community strong. We encourage participation from the most varied and diverse backgrounds possible and want to be very clear about where we stand. 6 | 7 | Our goal is to maintain a safe, helpful and friendly community for everyone, regardless of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other defining characteristic. 8 | 9 | This code and related procedures also apply to unacceptable behavior occurring outside the scope of community activities, in all community venues (online and in-person) as well as in all one-on-one communications, and anywhere such behavior has the potential to adversely affect the safety and well-being of community members. 10 | 11 | For more information on our code of conduct, please visit [https://slackhq.github.io/code-of-conduct](https://slackhq.github.io/code-of-conduct) -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | _metadata: 2 | major_version: 1 3 | minor_version: 1 4 | display_information: 5 | name: Tasks 6 | features: 7 | app_home: 8 | home_tab_enabled: true 9 | messages_tab_enabled: true 10 | messages_tab_read_only_enabled: true 11 | bot_user: 12 | display_name: Tasks 13 | always_online: false 14 | shortcuts: 15 | - name: Create a task 16 | type: global 17 | callback_id: global_new_task 18 | description: Quickly add a task to your list 19 | - name: Create a task 20 | type: message 21 | callback_id: message_new_task 22 | description: Create a task from this message 23 | oauth_config: 24 | scopes: 25 | bot: 26 | # Allows us to add the shortcuts for creating tasks 27 | - commands 28 | # Allows us to send task reminders via a DM from the bot 29 | - chat:write 30 | settings: 31 | event_subscriptions: 32 | bot_events: 33 | - app_home_opened 34 | interactivity: 35 | is_enabled: true 36 | org_deploy_enabled: false 37 | socket_mode_enabled: true 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 SlackAPI 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 | -------------------------------------------------------------------------------- /utilities/complete-tasks.js: -------------------------------------------------------------------------------- 1 | const { Op } = require('sequelize'); 2 | 3 | const { Task } = require('../models'); 4 | 5 | module.exports = async (taskIDs, slackUserID, client) => { 6 | Task.update({ status: 'CLOSED' }, { where: { id: taskIDs } }); 7 | // Find all the tasks provided where we have a scheduled message ID 8 | const tasksFromDB = await Task.findAll( 9 | { 10 | where: { 11 | [Op.and]: [ 12 | { id: taskIDs }, 13 | { scheduledMessageId: { [Op.not]: null } }, 14 | ], 15 | }, 16 | }, 17 | ); 18 | // If a reminder is scheduled, cancel it and remove the ID from the datastore 19 | tasksFromDB.map(async (task) => { 20 | if (task.scheduledMessageId) { 21 | try { 22 | await client.chat.deleteScheduledMessage({ 23 | channel: slackUserID, 24 | scheduled_message_id: task.scheduledMessageId, 25 | }); 26 | Task.update({ scheduledMessageId: null }, { where: { id: task.id } }); 27 | } catch (error) { 28 | // eslint-disable-next-line no-console 29 | console.error(error); 30 | } 31 | } 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest an idea for this project 3 | title: "[FEATURE] " 4 | labels: [enhancement, Needs Triage] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing request for this? 9 | description: Please search to see if an issue already exists for the feature you're requesting. 10 | options: 11 | - label: I have searched for any related issues and avoided creating a duplicate issue. 12 | required: true 13 | - type: checkboxes 14 | attributes: 15 | label: Have you read our code of conduct? 16 | description: All contributors must agree to our code of conduct, which you can find at https://slackhq.github.io/code-of-conduct 17 | options: 18 | - label: I have read and agree to the code of conduct 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Feature as a user story 23 | description: | 24 | **Example**: **As a** user, **I want to** view my task list, **So that I can** know what tasks I have outstanding 25 | render: markdown 26 | validations: 27 | required: true 28 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/block_app-home-nav-create-a-task.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | appHomeBlockChecklistSelectionActionPayload, 3 | } = require('./__fixtures__/action-fixtures'); 4 | 5 | const { 6 | testAction, 7 | testActionError, 8 | } = require('./__utils__/action-test-util-funcs'); 9 | 10 | const { 11 | appHomeNavCreateATaskCallback, 12 | } = require('../block_app-home-nav-create-a-task'); 13 | 14 | describe('App home nav create a task action callback function test ', () => { 15 | it('Acknowledges the action and reloads the app home', async () => { 16 | await testAction( 17 | appHomeBlockChecklistSelectionActionPayload, 18 | appHomeNavCreateATaskCallback, 19 | global.viewOpenMockFunc, 20 | { trigger_id: appHomeBlockChecklistSelectionActionPayload.trigger_id }, 21 | ); 22 | }); 23 | it('Logs an error when the the new view fails to be published', async () => { 24 | await testActionError( 25 | appHomeBlockChecklistSelectionActionPayload, 26 | appHomeNavCreateATaskCallback, 27 | global.viewOpenMockFunc, 28 | { trigger_id: appHomeBlockChecklistSelectionActionPayload.trigger_id }, 29 | ); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /listeners/events/__tests__/__utils__/event-test-util-funcs.js: -------------------------------------------------------------------------------- 1 | const { appHomeOpenedCallback } = require('../../app_home_opened'); 2 | 3 | /* -------------------- Functions for generating the inputs to the listener's callback functions. ------------------- */ 4 | 5 | const mockAppHomeEventCallbackInput = (mockAppHomeEvent) => ({ 6 | client: { 7 | views: { 8 | publish: global.viewPublishMockFunc, 9 | }, 10 | }, 11 | event: mockAppHomeEvent, 12 | // The body payload that contains the 'event' payload 13 | body: { 14 | team_id: 'T014K402SOW', 15 | event: mockAppHomeEvent, 16 | }, 17 | }); 18 | 19 | /* ------------------------------------- Utility functions for testing shortcuts ------------------------------------ */ 20 | 21 | const validateAppHomeOpenedCallback = async ( 22 | appHomeEventCallbackInput, 23 | publishArgs, 24 | ) => { 25 | await appHomeOpenedCallback(appHomeEventCallbackInput); 26 | 27 | expect(global.viewPublishMockFunc).toBeCalledTimes(1); 28 | expect(global.viewPublishMockFunc).toBeCalledWith( 29 | expect.objectContaining(publishArgs), 30 | ); 31 | }; 32 | 33 | module.exports = { 34 | validateAppHomeOpenedCallback, 35 | mockAppHomeEventCallbackInput, 36 | }; 37 | -------------------------------------------------------------------------------- /models/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Sequelize = require('sequelize'); 4 | 5 | const basename = path.basename(__filename); 6 | const env = process.env.NODE_ENV || 'development'; 7 | // eslint-disable-next-line import/no-dynamic-require 8 | const config = require(`${__dirname}/../config/config.json`)[env]; 9 | const db = {}; 10 | 11 | let sequelize; 12 | if (config.use_env_variable) { 13 | sequelize = new Sequelize(process.env[config.use_env_variable], config); 14 | } else { 15 | sequelize = new Sequelize(config.database, config.username, config.password, config); 16 | } 17 | 18 | fs 19 | .readdirSync(__dirname) 20 | .filter((file) => (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js')) 21 | .forEach((file) => { 22 | // eslint-disable-next-line import/no-dynamic-require,global-require 23 | const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); 24 | db[model.name] = model; 25 | }); 26 | 27 | Object.keys(db).forEach((modelName) => { 28 | if (db[modelName].associate) { 29 | db[modelName].associate(db); 30 | } 31 | }); 32 | 33 | db.sequelize = sequelize; 34 | db.Sequelize = Sequelize; 35 | 36 | module.exports = db; 37 | -------------------------------------------------------------------------------- /migrations/20210707155832-assign-user-to-task.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.renameColumn( 4 | 'Tasks', 5 | 'UserId', 6 | 'creatorId', 7 | { 8 | type: Sequelize.UUID, 9 | references: { 10 | model: 'Users', 11 | key: 'id', 12 | }, 13 | onUpdate: 'CASCADE', 14 | onDelete: 'SET NULL', 15 | }, 16 | ); 17 | await queryInterface.addColumn( 18 | 'Tasks', 19 | 'currentAssigneeId', 20 | { 21 | type: Sequelize.UUID, 22 | references: { 23 | model: 'Users', 24 | key: 'id', 25 | }, 26 | }, 27 | ); 28 | }, 29 | 30 | down: async (queryInterface, Sequelize) => { 31 | await queryInterface.removeColumn( 32 | 'Tasks', 33 | 'CurrentAssigneeId', 34 | ); 35 | await queryInterface.renameColumn( 36 | 'Tasks', 37 | 'creatorId', 38 | 'UserId', 39 | { 40 | type: Sequelize.UUID, 41 | references: { 42 | model: 'Users', 43 | key: 'id', 44 | }, 45 | onUpdate: 'CASCADE', 46 | onDelete: 'SET NULL', 47 | }, 48 | ); 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /.jest/mock-api-method-funcs.js: -------------------------------------------------------------------------------- 1 | // Here we define the mock api methods that will be used in our tests. 2 | 3 | // Mock for the client.views.open method 4 | global.viewOpenMockFunc = jest.fn(); 5 | // Mock for the client.views.publish method 6 | global.viewPublishMockFunc = jest.fn(); 7 | // Mocks the client.chat.update method 8 | global.chatUpdateMockFunc = jest.fn(); 9 | global.deleteScheduledMessageMockFunc = jest.fn(); 10 | // Mocks the client.chat.postMessage method 11 | // TODO: Respond with message id, update the main code to use the response message id 12 | global.chatPostMessageMockFunc = jest.fn(); 13 | // Mocks the client.chat.scheduleMessage method 14 | global.chatScheduleMessageMockFunc = jest.fn( 15 | async ({ channel, postAt, text }) => ({ 16 | ok: true, 17 | channel, 18 | scheduled_message_id: 'Q1298393284', 19 | post_at: postAt, 20 | message: { 21 | text, 22 | username: 'ecto1', 23 | bot_id: 'B19LU7CSY', 24 | attachments: [ 25 | { 26 | text: 'This is an attachment', 27 | id: 1, 28 | fallback: "This is an attachment's fallback", 29 | }, 30 | ], 31 | type: 'delayed_message', 32 | subtype: 'bot_message', 33 | }, 34 | }), 35 | ); 36 | // Mock for the ack() method 37 | global.ackMockFunc = jest.fn(); 38 | -------------------------------------------------------------------------------- /listeners/actions/index.js: -------------------------------------------------------------------------------- 1 | const { appHomeNavCompletedCallback } = require('./block_app-home-nav-completed'); 2 | const { appHomeNavCreateATaskCallback } = require('./block_app-home-nav-create-a-task'); 3 | const { appHomeNavOpenCallback } = require('./block_app-home-nav-open'); 4 | const { buttonMarkAsDoneCallback } = require('./block_button-mark-as-done'); 5 | const { reopenTaskCallback } = require('./block_reopen-task'); 6 | const { openTaskCheckboxClickedCallback } = require('./block_open_task_list_home'); 7 | 8 | module.exports.register = (app) => { 9 | app.action( 10 | { action_id: 'app-home-nav-completed', type: 'block_actions' }, 11 | appHomeNavCompletedCallback, 12 | ); 13 | app.action('app-home-nav-create-a-task', appHomeNavCreateATaskCallback); 14 | app.action( 15 | { action_id: 'app-home-nav-open', type: 'block_actions' }, 16 | appHomeNavOpenCallback, 17 | ); 18 | app.action( 19 | { action_id: 'button-mark-as-done', type: 'block_actions' }, 20 | buttonMarkAsDoneCallback, 21 | ); 22 | app.action( 23 | { action_id: 'reopen-task', type: 'block_actions' }, 24 | reopenTaskCallback, 25 | ); 26 | app.action( 27 | { 28 | action_id: 'blockOpenTaskCheckboxClicked', 29 | type: 'block_actions', 30 | }, 31 | openTaskCheckboxClickedCallback, 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tasks App 2 | 3 | 🚨 This app is under active development. 🚨 4 | 5 | Tasks App is a sample Task Management app built on the [Slack Platform](https://api.slack.com). To learn more about it, check out this [blog post](https://slack.com/intl/en-ie/blog/developers/sharpen-development-skills-tasks-app) or watch this ongoing series of videos on [YouTube](https://youtube.com/playlist?list=PLWlXaxtQ7fUb0B4uNTKirvrQ0JOTCBFae). 6 | 7 | As Tasks App is a tool designed to teach you about the Slack Platform, we don't currently offer a hosted version. If you're looking for a project management tool for your organisation, check out the many options available on our [App Directory](https://my.slack.com/apps/category/At0EFY3MJ4-project-management). 8 | 9 | If you want to see the app in action, there's some screenshots below, or you can clone the repo and run it locally. 10 | 11 | ## App code 12 | 13 | - [Setup](./docs/setup.md) 14 | - [Project structure](./docs/structure.md) 15 | ## Screenshots 16 | 17 | ### Tasks list on App Home 18 | ![Tasks list on App Home](./images/tasks-home-screen.png) 19 | 20 | ### Creating tasks with a Global Shortcut 21 | ![Creating tasks with a global shortcut](./images/tasks-shortcut.gif) 22 | 23 | ### Creating tasks from a message with a Message Shortcut 24 | ![Creating tasks from a message with a Message Shortcut](./images/tasks-message-shortcut.gif) 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slackapi-tasks-app", 3 | "version": "1.0.0", 4 | "description": "Tasks App is a sample Task Management app built on the Slack Platform.", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "jest --no-cache", 8 | "lint": "eslint . --ext .js", 9 | "build": "npx sequelize-cli db:migrate" 10 | }, 11 | "jest": { 12 | "collectCoverage": true 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/slackapi/tasks-app.git" 17 | }, 18 | "keywords": [ 19 | "slackapi" 20 | ], 21 | "author": "Slack Technologies", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/slackapi/tasks-app/issues" 25 | }, 26 | "homepage": "https://github.com/slackapi/tasks-app#readme", 27 | "dependencies": { 28 | "@slack/bolt": "^3.8.1", 29 | "dotenv": "^10.0.0", 30 | "eslint-plugin-jest": "^24.4.2", 31 | "luxon": "^1.28.1", 32 | "nodemon": "^2.0.20", 33 | "pluralize": "^8.0.0", 34 | "sequelize": "^6.6.5", 35 | "slack-block-builder": "^2.1.1", 36 | "sqlite3": "^4.2.0" 37 | }, 38 | "devDependencies": { 39 | "eslint": "^7.32.0", 40 | "eslint-config-airbnb-base": "^14.2.1", 41 | "eslint-config-prettier": "^8.3.0", 42 | "eslint-plugin-import": "^2.24.2", 43 | "jest": "^27.2.0", 44 | "sequelize-cli": "^6.3.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /user-interface/messages/__tests__/task-reminder.test.js: -------------------------------------------------------------------------------- 1 | const { taskReminder } = require('../index'); 2 | 3 | test('Returns payload for the task reminder API call', () => { 4 | const taskTitle = 'Test Task'; 5 | const channel = 'C1234567'; 6 | const postAt = 123456789; 7 | const dueDate = 'Some date'; 8 | const taskID = 1; 9 | const expected = { 10 | channel, 11 | post_at: postAt, 12 | text: `You asked me to remind you about "${taskTitle}".`, 13 | blocks: [ 14 | { 15 | text: { 16 | type: 'mrkdwn', 17 | text: `:wave: You asked me to remind you about "*${taskTitle}*".`, 18 | }, 19 | accessory: { 20 | text: { 21 | type: 'plain_text', 22 | text: 'Mark as done', 23 | }, 24 | value: `task-${taskID}`, 25 | action_id: 'button-mark-as-done', 26 | type: 'button', 27 | }, 28 | type: 'section', 29 | }, 30 | { 31 | fields: [ 32 | { type: 'mrkdwn', text: '*Task title*' }, 33 | { type: 'mrkdwn', text: '*Due date*' }, 34 | { type: 'mrkdwn', text: taskTitle }, 35 | { type: 'mrkdwn', text: dueDate }, 36 | ], 37 | type: 'section', 38 | }, 39 | ], 40 | }; 41 | expect(taskReminder(postAt, channel, taskTitle, dueDate, taskID)) 42 | .toEqual(expected); 43 | }); 44 | -------------------------------------------------------------------------------- /user-interface/modals/new-task.js: -------------------------------------------------------------------------------- 1 | const { Modal, Blocks, Elements } = require('slack-block-builder'); 2 | 3 | module.exports = (prefilledTitle, currentUser) => { 4 | const textInput = (taskTitle) => { 5 | if (taskTitle) { 6 | return Elements.TextInput({ 7 | placeholder: 'Do this thing', 8 | actionId: 'taskTitle', 9 | initialValue: taskTitle, 10 | }); 11 | } 12 | return Elements.TextInput({ 13 | placeholder: 'Do this thing', 14 | actionId: 'taskTitle', 15 | }); 16 | }; 17 | 18 | return Modal({ title: 'Create new task', submit: 'Create', callbackId: 'new-task-modal' }) 19 | .blocks( 20 | Blocks.Input({ label: 'New task', blockId: 'taskTitle' }).element( 21 | textInput(prefilledTitle), 22 | ), 23 | Blocks.Input({ label: 'Assign user', blockId: 'taskAssignUser' }).element( 24 | Elements.UserSelect({ 25 | actionId: 'taskAssignUser', 26 | }).initialUser(currentUser), 27 | ), 28 | Blocks.Input({ label: 'Due date', blockId: 'taskDueDate', optional: true }).element( 29 | Elements.DatePicker({ 30 | actionId: 'taskDueDate', 31 | }), 32 | ), 33 | Blocks.Input({ label: 'Time', blockId: 'taskDueTime', optional: true }).element( 34 | Elements.TimePicker({ 35 | actionId: 'taskDueTime', 36 | }), 37 | ), 38 | ).buildToJSON(); 39 | }; 40 | -------------------------------------------------------------------------------- /listeners/shortcuts/__tests__/__fixtures__/shortcut-fixtures.js: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------- Hard-coded payload data -------------------------------------------- */ 2 | 3 | const mockGlobalShortcutPayloadData = { 4 | type: 'shortcut', 5 | token: 'XXXXXXXXXXXXX', 6 | action_ts: '1581106241.371594', 7 | team: { 8 | id: 'TXXXXXXXX', 9 | domain: 'shortcuts-test', 10 | }, 11 | user: { 12 | id: 'UXXXXXXXXX', 13 | username: 'aman', 14 | team_id: 'TXXXXXXXX', 15 | }, 16 | callback_id: 'global_new_task', 17 | trigger_id: '944799105734.773906753841.38b5894552bdd4a780554ee59d1f3638', 18 | }; 19 | 20 | const mockMessageShortcutPayloadData = { 21 | callback_id: 'message_new_task', 22 | type: 'message_action', 23 | trigger_id: '13345224609.8534564800.6f8ab1f53e13d0cd15f96106292d5536', 24 | response_url: 25 | 'https://hooks.slack.com/app-actions/T0MJR11A4/21974584944/yk1S9ndf35Q1flupVG5JbpM6', 26 | team: { 27 | id: 'T0MJRM1A7', 28 | domain: 'pandamonium', 29 | }, 30 | channel: { 31 | id: 'D0LFFBKLZ', 32 | name: 'cats', 33 | }, 34 | user: { 35 | id: 'U0D15K92L', 36 | name: 'dr_maomao', 37 | }, 38 | message: { 39 | type: 'message', 40 | user: 'U0MJRG1AL', 41 | ts: '1516229207.000133', 42 | text: "World's smallest big cat! <https://youtube.com/watch?v=W86cTIoMv2U>", 43 | }, 44 | }; 45 | 46 | module.exports = { 47 | mockGlobalShortcutPayloadData, 48 | mockMessageShortcutPayloadData, 49 | }; 50 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const { App, LogLevel } = require('@slack/bolt'); 4 | 5 | const { Sequelize } = require('sequelize'); 6 | 7 | const sequelize = new Sequelize(process.env.DB_URI); 8 | 9 | const { registerListeners } = require('./listeners'); 10 | 11 | let logLevel; 12 | switch (process.env.LOG_LEVEL) { 13 | case 'debug': 14 | logLevel = LogLevel.DEBUG; 15 | break; 16 | case 'info': 17 | logLevel = LogLevel.INFO; 18 | break; 19 | case 'warn': 20 | logLevel = LogLevel.WARN; 21 | break; 22 | case 'error': 23 | logLevel = LogLevel.ERROR; 24 | break; 25 | default: 26 | logLevel = LogLevel.INFO; 27 | } 28 | 29 | // Initializes your app with your bot token and signing secret 30 | const app = new App({ 31 | token: process.env.SLACK_BOT_TOKEN, 32 | socketMode: true, 33 | appToken: process.env.SLACK_APP_TOKEN, 34 | logLevel, 35 | }); 36 | registerListeners(app); 37 | 38 | (async () => { 39 | try { 40 | await sequelize.authenticate(); 41 | await sequelize.sync({ force: true }); 42 | // eslint-disable-next-line no-console 43 | console.log('All models were synchronized successfully.'); 44 | // eslint-disable-next-line no-console 45 | console.log('Connection has been established successfully.'); 46 | // Start your app 47 | await app.start(); 48 | 49 | // eslint-disable-next-line no-console 50 | console.log('⚡️ Tasks app is running!'); 51 | } catch (error) { 52 | // eslint-disable-next-line no-console 53 | console.error('Unable to start App', error); 54 | process.exit(1); 55 | } 56 | })(); 57 | -------------------------------------------------------------------------------- /listeners/shortcuts/__tests__/__utils__/shortcut-test-util-funcs.js: -------------------------------------------------------------------------------- 1 | /* -------------------- Functions for generating the inputs to the listener's callback functions. ------------------- */ 2 | 3 | const mockShortcutCallbackInput = (shortcutPayload) => ({ 4 | ack: global.ackMockFunc, 5 | shortcut: shortcutPayload, 6 | client: { 7 | views: { 8 | open: global.viewOpenMockFunc, 9 | }, 10 | }, 11 | }); 12 | 13 | /* -------------------------- Utility functions for testing the listener callback functions ------------------------- */ 14 | 15 | const testShortcut = async ( 16 | mockShortcutPayloadData, 17 | shortcutCallback, 18 | apiMethod = global.viewOpenMockFunc, 19 | ) => { 20 | const callbackInput = mockShortcutCallbackInput(mockShortcutPayloadData); 21 | 22 | const callbackFunctionPromiseToTest = shortcutCallback(callbackInput); 23 | const mockedApiMethod = apiMethod; 24 | const mockedApiMethodArgObj = { 25 | trigger_id: mockShortcutPayloadData.trigger_id, 26 | view: expect.any(String), 27 | }; 28 | 29 | const apiMethodsToCall = [{ mockedApiMethod, mockedApiMethodArgObj }]; 30 | 31 | await global.testListener(callbackFunctionPromiseToTest, apiMethodsToCall); 32 | }; 33 | 34 | const testShortcutError = async ( 35 | mockShortcutPayloadData, 36 | shortcutCallback, 37 | methodToFail = global.viewOpenMockFunc, 38 | ) => { 39 | const callbackInput = mockShortcutCallbackInput(mockShortcutPayloadData); 40 | await global.testErrorLog(shortcutCallback(callbackInput), methodToFail); 41 | }; 42 | 43 | module.exports = { 44 | testShortcut, 45 | testShortcutError, 46 | }; 47 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/block_button-mark-as-done.test.js: -------------------------------------------------------------------------------- 1 | const { messageBlockActionPayload } = require('./__fixtures__/action-fixtures'); 2 | 3 | const { 4 | testAction, 5 | testActionError, 6 | } = require('./__utils__/action-test-util-funcs'); 7 | const { buttonMarkAsDoneCallback } = require('../block_button-mark-as-done'); 8 | 9 | describe('App home nav open action callback function test ', () => { 10 | it('Acknowledges the action and reloads the app home', async () => { 11 | await testAction( 12 | messageBlockActionPayload, 13 | buttonMarkAsDoneCallback, 14 | global.chatUpdateMockFunc, 15 | { 16 | channel: messageBlockActionPayload.container.channel_id, 17 | ts: messageBlockActionPayload.container.message_ts, 18 | text: `~${messageBlockActionPayload.message.text}~`, 19 | blocks: [], // Remove all the existing blocks, just leaving the text above. 20 | }, 21 | ); 22 | }); 23 | it('Logs an error when the the new view fails to be published', async () => { 24 | // TODO: Remove the arguments for the methods on the fail condition, we dont need them when testing for failure 25 | await testActionError( 26 | messageBlockActionPayload, 27 | buttonMarkAsDoneCallback, 28 | global.chatUpdateMockFunc, 29 | { 30 | channel: messageBlockActionPayload.container.channel_id, 31 | ts: messageBlockActionPayload.container.message_ts, 32 | text: `~${messageBlockActionPayload.message.text}~`, 33 | blocks: [], // Remove all the existing blocks, just leaving the text above. 34 | }, 35 | ); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /utilities/reload-app-home.js: -------------------------------------------------------------------------------- 1 | const { Op } = require('sequelize'); 2 | 3 | const { 4 | openTasksView, 5 | completedTasksView, 6 | } = require('../user-interface/app-home'); 7 | const { User, Task } = require('../models'); 8 | 9 | module.exports = async (client, slackUserID, slackWorkspaceID, navTab) => { 10 | try { 11 | const queryResult = await User.findOrCreate({ 12 | where: { 13 | slackUserID, 14 | slackWorkspaceID, 15 | }, 16 | include: [ 17 | { 18 | model: Task, 19 | as: 'createdTasks', 20 | }, 21 | { 22 | model: Task, 23 | as: 'assignedTasks', 24 | }, 25 | ], 26 | }); 27 | const user = queryResult[0]; 28 | 29 | if (navTab === 'completed') { 30 | const recentlyCompletedTasks = await user.getAssignedTasks({ 31 | where: { 32 | status: 'CLOSED', 33 | updatedAt: { 34 | [Op.gt]: new Date(new Date() - 24 * 60 * 60 * 1000), 35 | }, 36 | }, 37 | }); 38 | 39 | await client.views.publish({ 40 | user_id: slackUserID, 41 | view: completedTasksView(recentlyCompletedTasks), 42 | }); 43 | return; 44 | } 45 | 46 | const openTasks = await user.getAssignedTasks({ 47 | where: { 48 | status: 'OPEN', 49 | }, 50 | order: [['dueDate', 'ASC']], 51 | }); 52 | 53 | await client.views.publish({ 54 | user_id: slackUserID, 55 | view: openTasksView(openTasks), 56 | }); 57 | } catch (error) { 58 | // eslint-disable-next-line no-console 59 | console.error(error); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /user-interface/app-home/completed-tasks-view.js: -------------------------------------------------------------------------------- 1 | const { 2 | HomeTab, 3 | Header, 4 | Divider, 5 | Section, 6 | Actions, 7 | Elements, 8 | } = require('slack-block-builder'); 9 | const pluralize = require('pluralize'); 10 | 11 | module.exports = (recentlyCompletedTasks) => { 12 | const homeTab = HomeTab({ 13 | callbackId: 'tasks-home', 14 | privateMetaData: 'completed', 15 | }).blocks( 16 | Actions({ blockId: 'task-creation-actions' }).elements( 17 | Elements.Button({ text: 'Open tasks' }) 18 | .value('app-home-nav-open') 19 | .actionId('app-home-nav-open'), 20 | Elements.Button({ text: 'Completed tasks' }) 21 | .value('app-home-nav-completed') 22 | .actionId('app-home-nav-completed') 23 | .primary(true), 24 | Elements.Button({ text: 'Create a task' }) 25 | .value('app-home-nav-create-a-task') 26 | .actionId('app-home-nav-create-a-task'), 27 | ), 28 | ); 29 | 30 | if (recentlyCompletedTasks.length === 0) { 31 | homeTab.blocks( 32 | Header({ text: 'No completed tasks' }), 33 | Divider(), 34 | Section({ text: "Looks like you've got nothing completed." }), 35 | ); 36 | return homeTab.buildToJSON(); 37 | } 38 | 39 | const completedTaskList = recentlyCompletedTasks.map((task) => 40 | Section({ text: `• ~${task.title}~` }).accessory( 41 | Elements.Button({ text: 'Reopen' }) 42 | .value(`${task.id}`) 43 | .actionId('reopen-task'), 44 | ), 45 | ); 46 | 47 | homeTab.blocks( 48 | Header({ 49 | text: `You have ${ 50 | recentlyCompletedTasks.length 51 | } recently completed ${pluralize('task', recentlyCompletedTasks.length)}`, 52 | }), 53 | Divider(), 54 | completedTaskList, 55 | ); 56 | 57 | return homeTab.buildToJSON(); 58 | }; 59 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/__utils__/action-test-util-funcs.js: -------------------------------------------------------------------------------- 1 | /* -------------------- Functions for generating the inputs to the listener's callback functions. ------------------- */ 2 | 3 | const mockActionCallbackInput = (actionPayload) => ({ 4 | ack: global.ackMockFunc, 5 | body: actionPayload, 6 | client: { 7 | views: { 8 | publish: global.viewPublishMockFunc, 9 | open: global.viewOpenMockFunc, 10 | }, 11 | chat: { 12 | update: global.chatUpdateMockFunc, 13 | deleteScheduledMessage: global.deleteScheduledMessageMockFunc, 14 | }, 15 | }, 16 | action: actionPayload.actions[0], 17 | }); 18 | 19 | /* -------------------------- Utility functions for testing the listener callback functions ------------------------- */ 20 | // TODO: There's a ton of commonalities between the different action tests, maybe there's room for refactoring across them 21 | // Maybe a utility function that also sets up the test cases :think: 22 | 23 | const testAction = async ( 24 | mockActionPayloadData, 25 | actionCallback, 26 | mockedApiMethod = global.viewPublishMockFunc, 27 | mockedApiMethodArgObj = { 28 | user_id: mockActionPayloadData.user.id, 29 | view: expect.any(String), 30 | }, 31 | ) => { 32 | const callbackInput = mockActionCallbackInput(mockActionPayloadData); 33 | 34 | const callbackFunctionPromiseToTest = actionCallback(callbackInput); 35 | 36 | const apiMethodsToCall = [{ mockedApiMethod, mockedApiMethodArgObj }]; 37 | 38 | await global.testListener(callbackFunctionPromiseToTest, apiMethodsToCall); 39 | }; 40 | 41 | const testActionError = async ( 42 | mockActionPayloadData, 43 | actionCallback, 44 | methodToFail = global.viewPublishMockFunc, 45 | ) => { 46 | const callbackInput = mockActionCallbackInput(mockActionPayloadData); 47 | await global.testErrorLog(actionCallback(callbackInput), methodToFail); 48 | }; 49 | 50 | module.exports = { 51 | testAction, 52 | testActionError, 53 | }; 54 | -------------------------------------------------------------------------------- /user-interface/app-home/__tests__/open-tasks-view.test.js: -------------------------------------------------------------------------------- 1 | const { openTasksView } = require('../index'); 2 | 3 | test('Returns blocks for the open task list home view if no tasks available', () => { 4 | const expected = { 5 | callback_id: 'tasks-home', 6 | private_metadata: 'open', 7 | blocks: [ 8 | { 9 | block_id: 'task-creation-actions', 10 | elements: [ 11 | { 12 | text: { 13 | type: 'plain_text', 14 | text: 'Open tasks', 15 | }, 16 | value: 'app-home-nav-open', 17 | action_id: 'app-home-nav-open', 18 | style: 'primary', 19 | type: 'button', 20 | }, 21 | { 22 | text: { 23 | type: 'plain_text', 24 | text: 'Completed tasks', 25 | }, 26 | value: 'app-home-nav-completed', 27 | action_id: 'app-home-nav-completed', 28 | type: 'button', 29 | }, 30 | { 31 | text: { 32 | type: 'plain_text', 33 | text: 'Create a task', 34 | }, 35 | value: 'app-home-nav-create-a-task', 36 | action_id: 'app-home-nav-create-a-task', 37 | type: 'button', 38 | }, 39 | ], 40 | type: 'actions', 41 | }, 42 | { 43 | text: { 44 | type: 'plain_text', 45 | text: 'No open tasks', 46 | }, 47 | type: 'header', 48 | }, 49 | { 50 | type: 'divider', 51 | }, 52 | { 53 | text: { 54 | type: 'mrkdwn', 55 | text: "Looks like you've got nothing to do.", 56 | }, 57 | type: 'section', 58 | }, 59 | ], 60 | type: 'home', 61 | }; 62 | expect(openTasksView([])).toEqual(JSON.stringify(expected)); 63 | }); 64 | 65 | test.todo('Returns blocks for the open task list home view if open tasks available'); 66 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup Tasks App 2 | 3 | ## Installing 4 | 5 | **Create Slack App** 6 | 1. Open [https://api.slack.com/apps/new](https://api.slack.com/apps/new) and choose "From an app manifest" 7 | 2. Choose the workspace you want to install the application to 8 | 3. Copy the contents of [manifest.yml](../manifest.yml) into the text box that says `*Paste your manifest code here*` and click *Next* 9 | 4. Review the configuration and click *Create* 10 | 5. Now click *Install to Workspace* and *Allow* on the screen that follows. You'll be redirected to the App Configuration dashboard. 11 | 12 | **Environment variables** 13 | Before you can run the app, you'll need to store some environment variables. 14 | 15 | 1. Copy `.env.sample` to `.env` 16 | 2. Open your apps configuration page from [this list](https://api.slack.com/apps), click *OAuth & Permissions* in the left hand menu, then copy the *Bot User OAuth Token* into your `.env` file under `SLACK_BOT_TOKEN` 17 | 3. Click *Basic Information* from the left hand menu and follow the steps in the *App-Level Tokens* section to create an app-level token with the `connections:write` scope. Copy that token into your `.env` as `SLACK_APP_TOKEN`. 18 | 4. For the `DB_URI` value, you should enter the URI of the database system you plan to store tasks in. In the `.env.sample` file, we assume you're using [SQLite](https://www.sqlite.org/index.html), but you can use any system supported by [Sequelize](https://sequelize.org/) 19 | 20 | **Install dependencies** 21 | 22 | `npm install` 23 | 24 | *NOTE*: By default, Tasks App installs `sqlite3`, but as mentioned above, you can use any system supported by [Sequelize](https://sequelize.org/), just `npm install` the relevant package, e.g. `npm install mysql2` 25 | 26 | **Run database migrations** 27 | `npx sequelize-cli db:migrate` 28 | 29 | **Run Bolt Server** 30 | 31 | `node app.js` 32 | 33 | ## Tutorial 34 | 35 | More of a visual person? You can checkout [a video walkthrough](https://www.youtube.com/watch?v=q3SBz_eqOq0) of these instructions. 36 | -------------------------------------------------------------------------------- /user-interface/app-home/__tests__/completed-tasks-view.test.js: -------------------------------------------------------------------------------- 1 | const { completedTasksView } = require('../index'); 2 | 3 | test('Returns blocks for the completed task list home view if no tasks available', () => { 4 | const expected = { 5 | callback_id: 'tasks-home', 6 | private_metadata: 'completed', 7 | blocks: [ 8 | { 9 | block_id: 'task-creation-actions', 10 | elements: [ 11 | { 12 | text: { 13 | type: 'plain_text', 14 | text: 'Open tasks', 15 | }, 16 | value: 'app-home-nav-open', 17 | action_id: 'app-home-nav-open', 18 | type: 'button', 19 | }, 20 | { 21 | text: { 22 | type: 'plain_text', 23 | text: 'Completed tasks', 24 | }, 25 | value: 'app-home-nav-completed', 26 | action_id: 'app-home-nav-completed', 27 | style: 'primary', 28 | type: 'button', 29 | }, 30 | { 31 | text: { 32 | type: 'plain_text', 33 | text: 'Create a task', 34 | }, 35 | value: 'app-home-nav-create-a-task', 36 | action_id: 'app-home-nav-create-a-task', 37 | type: 'button', 38 | }, 39 | ], 40 | type: 'actions', 41 | }, 42 | { 43 | text: { 44 | type: 'plain_text', 45 | text: 'No completed tasks', 46 | }, 47 | type: 'header', 48 | }, 49 | { 50 | type: 'divider', 51 | }, 52 | { 53 | text: { 54 | type: 'mrkdwn', 55 | text: "Looks like you've got nothing completed.", 56 | }, 57 | type: 'section', 58 | }, 59 | ], 60 | type: 'home', 61 | }; 62 | expect(completedTasksView([])).toEqual(JSON.stringify(expected)); 63 | }); 64 | 65 | test.todo( 66 | 'Returns blocks for the completed task list home view if completed tasks available', 67 | ); 68 | -------------------------------------------------------------------------------- /listeners/views/__tests__/__utils__/view-test-util-funcs.js: -------------------------------------------------------------------------------- 1 | /* -------------------- Functions for generating the inputs to the listener's callback functions. ------------------- */ 2 | 3 | const mockViewCallbackInput = (viewPayload) => ({ 4 | ack: global.ackMockFunc, 5 | body: viewPayload, 6 | client: { 7 | chat: { 8 | postMessage: global.chatPostMessageMockFunc, 9 | scheduleMessage: global.chatScheduleMessageMockFunc, 10 | }, 11 | views: { 12 | publish: global.viewPublishMockFunc, 13 | }, 14 | }, 15 | view: viewPayload.view, 16 | }); 17 | 18 | /* -------------------------- Utility functions for testing the listener callback functions ------------------------- */ 19 | 20 | const testView = async ( 21 | mockViewPayloadData, 22 | viewCallback, 23 | mockedApiMethod, 24 | mockedApiMethodArgObj, 25 | ) => { 26 | const callbackInput = mockViewCallbackInput(mockViewPayloadData); 27 | 28 | const callbackFunctionPromiseToTest = viewCallback(callbackInput); 29 | 30 | const apiMethodsToCall = [{ mockedApiMethod, mockedApiMethodArgObj }]; 31 | 32 | await global.testListener(callbackFunctionPromiseToTest, apiMethodsToCall); 33 | }; 34 | 35 | const testViewAckError = async ( 36 | mockViewPayloadData, 37 | viewCallback, 38 | ackParameters, 39 | ) => { 40 | const callbackInput = mockViewCallbackInput(mockViewPayloadData); 41 | 42 | // Most listeners call ack() with no parameters, but some like the view listener use it to pass errors 43 | // TODO: Possibly incorporate this case (ack with parameters) in the global.testListener function 44 | 45 | await viewCallback(callbackInput); 46 | 47 | expect(global.ackMockFunc).toHaveBeenLastCalledWith( 48 | expect.objectContaining(ackParameters), 49 | ); 50 | }; 51 | 52 | const testViewError = async ( 53 | mockActionPayloadData, 54 | actionCallback, 55 | methodToFail, 56 | ) => { 57 | const callbackInput = mockViewCallbackInput(mockActionPayloadData); 58 | await global.testErrorLog(actionCallback(callbackInput), methodToFail); 59 | }; 60 | 61 | module.exports = { 62 | testView, 63 | testViewError, 64 | testViewAckError, 65 | }; 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug/issue 3 | title: "[BUG] <title>" 4 | labels: [Bug, Needs Triage] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search to see if an issue already exists for the bug you encountered. 10 | options: 11 | - label: I have searched for any related issues and avoided creating a duplicate issue. 12 | required: true 13 | - type: checkboxes 14 | attributes: 15 | label: Have you read our code of conduct? 16 | description: All contributors must agree to our code of conduct, which you can find at https://slackhq.github.io/code-of-conduct 17 | options: 18 | - label: I have read and agree to the code of conduct 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Current Behavior 23 | description: A concise description of what you're experiencing. 24 | validations: 25 | required: true 26 | - type: textarea 27 | attributes: 28 | label: Expected Behavior 29 | description: A concise description of what you expected to happen. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Steps To Reproduce 35 | description: Steps to reproduce the behavior. 36 | placeholder: | 37 | 1. In this environment... 38 | 2. With this config... 39 | 3. Run '...' 40 | 4. See error... 41 | validations: 42 | required: true 43 | - type: textarea 44 | attributes: 45 | label: Environment 46 | description: | 47 | examples: 48 | - **OS**: Ubuntu 20.04 49 | - **Language version**: Node 13.14.0 50 | value: | 51 | - OS: 52 | - Language version: 53 | render: markdown 54 | validations: 55 | required: false 56 | - type: textarea 57 | attributes: 58 | label: Anything else? 59 | description: | 60 | Links? References? Anything that will give us more context about the issue you are encountering! 61 | 62 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 63 | validations: 64 | required: false 65 | -------------------------------------------------------------------------------- /docs/structure.md: -------------------------------------------------------------------------------- 1 | # Project structure 2 | 3 | ## `manifest.yml` 4 | 5 | `manifest.yml` is a YAML-formatted configurations bundle for Slack apps. With a manifest, you can create an app with a pre-defined configuration, or adjust the configuration of an existing app. 6 | 7 | ## `app.js` 8 | 9 | `app.js` is the entry point for the application and is the file you'll run using `node` to start the server. The project aims to keep this file as thin as possible, primarily using it as a way to route inbound requests. 10 | 11 | ## `/docs` 12 | 13 | The folder contains all the documentation for the project, like this file. 14 | 15 | ## `/listeners` 16 | 17 | Every incoming request to Tasks App is routed to a "listener". Inside this directory, we group each listener based on the Slack Platform feature used, so `/listeners/shortcuts` handles incoming [Shortcuts](https://api.slack.com/interactivity/shortcuts) requests, `/listeners/views` handles [View submissions](https://api.slack.com/reference/interaction-payloads/views#view_submission) and so on. 18 | 19 | ## `/user-interface` 20 | 21 | All user interface in a Slack App is expressed using [Block Kit](https://api.slack.com/block-kit). This folder contains all the Block Kit code for the app. For ease of reading, Tasks App uses a community library called [Block Builder](https://github.com/raycharius/slack-block-builder/), but this isn't a requirement of Slack Apps. You're free to use a different library such as [JSX Slack](https://github.com/yhatt/jsx-slack), or even regular JSON. The Slack Platform endpoints consume blocks as JSON, so whichever approach you take, as long as it can output valid Block Kit JSON, will be fine. 22 | 23 | ## `/models` 24 | 25 | To make it easier to switch between data store types, we use [Sequelize](https://sequelize.org/) to handle all the database interactions. By default we use SQLite to simplify local development, but any of the systems that Sequelize supports should work. The `/models` folder contains the structure of all the data objects used. 26 | 27 | ## `/migrations` 28 | 29 | These are files related to the database migrations performed with Sequelize. These migrations should be the **only way** that the structure of the database is changed. -------------------------------------------------------------------------------- /user-interface/app-home/open-tasks-view.js: -------------------------------------------------------------------------------- 1 | const { 2 | HomeTab, Header, Divider, Section, Actions, Elements, Input, Bits, 3 | } = require('slack-block-builder'); 4 | const pluralize = require('pluralize'); 5 | const { DateTime } = require('luxon'); 6 | 7 | module.exports = (openTasks) => { 8 | const homeTab = HomeTab({ callbackId: 'tasks-home', privateMetaData: 'open' }).blocks( 9 | Actions({ blockId: 'task-creation-actions' }).elements( 10 | Elements.Button({ text: 'Open tasks' }).value('app-home-nav-open').actionId('app-home-nav-open').primary(true), 11 | Elements.Button({ text: 'Completed tasks' }).value('app-home-nav-completed').actionId('app-home-nav-completed'), 12 | Elements.Button({ text: 'Create a task' }).value('app-home-nav-create-a-task').actionId('app-home-nav-create-a-task'), 13 | ), 14 | ); 15 | 16 | if (openTasks.length === 0) { 17 | homeTab.blocks( 18 | Header({ text: 'No open tasks' }), 19 | Divider(), 20 | Section({ text: 'Looks like you\'ve got nothing to do.' }), 21 | ); 22 | return homeTab.buildToJSON(); 23 | } 24 | 25 | /* 26 | Block kit Options have a maximum length of 10, and most people have more than 10 open tasks 27 | at a given time, so we break the openTasks list into chunks of ten 28 | and add them as multiple blocks. 29 | */ 30 | const tasksInputsArray = []; 31 | let holdingArray = []; 32 | let start = 0; 33 | const end = openTasks.length; 34 | const maxOptionsLength = 10; 35 | 36 | for (start, end; start < end; start += maxOptionsLength) { 37 | holdingArray = openTasks.slice(start, start + maxOptionsLength); 38 | tasksInputsArray.push( 39 | Input({ label: ' ', blockId: `open-task-status-change-${start}` }).dispatchAction().element(Elements.Checkboxes({ actionId: 'blockOpenTaskCheckboxClicked' }).options(holdingArray.map((task) => { 40 | const option = { 41 | text: `*${task.title}*`, 42 | value: `open-task-${task.id}`, 43 | }; 44 | if (task.dueDate) { 45 | option.description = `Due ${DateTime.fromJSDate(task.dueDate).toRelativeCalendar()}`; 46 | } 47 | return Bits.Option(option); 48 | }))), 49 | ); 50 | } 51 | homeTab.blocks( 52 | Header({ text: `You have ${openTasks.length} open ${pluralize('task', openTasks.length)}` }), 53 | Divider(), 54 | tasksInputsArray, 55 | ); 56 | 57 | return homeTab.buildToJSON(); 58 | }; 59 | -------------------------------------------------------------------------------- /listeners/events/__tests__/app_home_opened.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | validateAppHomeOpenedCallback, 3 | mockAppHomeEventCallbackInput, 4 | } = require('./__utils__/event-test-util-funcs'); 5 | const { 6 | mockAppHomeOpenedEventNewUser, 7 | mockAppHomeOpenedEventExistingUser, 8 | } = require('./__fixtures__/event-fixtures'); 9 | const { 10 | openTasksView, 11 | completedTasksView, 12 | } = require('../../../user-interface/app-home'); 13 | 14 | const userId = mockAppHomeOpenedEventNewUser.user; 15 | 16 | describe('app_home_opened event callback function test ', () => { 17 | it('should call the callback func correctly for a new user who opened the app home', async () => { 18 | const mockAppHomeEventCallbackNewUserInput = mockAppHomeEventCallbackInput( 19 | mockAppHomeOpenedEventNewUser, 20 | ); 21 | 22 | await validateAppHomeOpenedCallback(mockAppHomeEventCallbackNewUserInput, { 23 | user_id: userId, 24 | view: openTasksView([]), 25 | }); 26 | }); 27 | 28 | it('should call the callback func correctly for an existing user who opened the app home Open Tasks tab with no open tasks', async () => { 29 | const mockAppHomeEventCallbackExistingUserInput = 30 | mockAppHomeEventCallbackInput(mockAppHomeOpenedEventExistingUser); 31 | // The private_metadata is set to open when the Open Tasks tab is opened 32 | mockAppHomeEventCallbackExistingUserInput.event.view.private_metadata = 33 | 'open'; 34 | 35 | await validateAppHomeOpenedCallback( 36 | mockAppHomeEventCallbackExistingUserInput, 37 | { 38 | user_id: userId, 39 | view: openTasksView([]), 40 | }, 41 | ); 42 | }); 43 | 44 | it('should call the callback func correctly for an existing user who opened the app home Completed Tasks tab with no completed tasks', async () => { 45 | const mockAppHomeEventCallbackExistingUserInput = 46 | mockAppHomeEventCallbackInput(mockAppHomeOpenedEventExistingUser); 47 | 48 | // The private_metadata is set to completed when the Completed Tasks tab is opened 49 | mockAppHomeEventCallbackExistingUserInput.event.view.private_metadata = 50 | 'completed'; 51 | 52 | await validateAppHomeOpenedCallback( 53 | mockAppHomeEventCallbackExistingUserInput, 54 | { 55 | user_id: userId, 56 | view: completedTasksView([]), 57 | }, 58 | ); 59 | }); 60 | }); 61 | 62 | // TODO: Existing user with open tasks 63 | 64 | // TODO: Existing user with completed tasks 65 | 66 | // TODO: Error out 67 | 68 | // TODO: event.tab !== 'home' 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | 3 | # Ignore Database config from sequelize CLI 4 | *.sqlite3 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variables file 78 | .env 79 | .env.test 80 | .env.production 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # Serverless directories 104 | .serverless/ 105 | 106 | # FuseBox cache 107 | .fusebox/ 108 | 109 | # DynamoDB Local files 110 | .dynamodb/ 111 | 112 | # TernJS port file 113 | .tern-port 114 | 115 | # Stores VSCode versions used for testing VSCode extensions 116 | .vscode-test 117 | 118 | # yarn v2 119 | .yarn/cache 120 | .yarn/unplugged 121 | .yarn/build-state.yml 122 | .yarn/install-state.gz 123 | .pnp.* 124 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '27 21 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.jest/global-helper-funcs.js: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------- Global helper functions -------------------------------------------- */ 2 | 3 | // A helper function to parse and validate JSON. 4 | global.isValidJSON = (jsonString) => { 5 | try { 6 | var obj = JSON.parse(jsonString); 7 | 8 | // Handle non-exception-throwing cases: 9 | // Neither JSON.parse(false) or JSON.parse(1234) throw errors, hence the type-checking, 10 | // but... JSON.parse(null) returns null, and typeof null === "object", 11 | // so we must check to make sure the object isn't null as well. 12 | // Thankfully, null is falsy, so this suffices: 13 | if (obj && typeof obj === 'object') { 14 | return obj; 15 | } 16 | } catch (e) {} 17 | 18 | return false; 19 | }; 20 | 21 | // A helper function to test if a listener's callback function successfully runs and calls all the API methods that it should with the proper arguments 22 | // apiMethods is an array of objects every object contains the mocked version of the API method and the arguments that should be passed to it 23 | global.testListener = async ( 24 | callbackFunctionPromiseToTest, 25 | apiMethodsToCall, 26 | usesAck = true, 27 | ) => { 28 | // Call the callback function 29 | await callbackFunctionPromiseToTest; 30 | 31 | for (const apiMethod of apiMethodsToCall) { 32 | const mockedFunction = apiMethod['mockedApiMethod']; 33 | const mockedFunctionArgument = apiMethod['mockedApiMethodArgObj']; 34 | 35 | expect(mockedFunction).toBeCalledWith( 36 | expect.objectContaining(mockedFunctionArgument), 37 | ); 38 | 39 | // If the argument contains a "view" key (ex. in the case of the client.views.publish method), we check that the blocks are valid JSON 40 | // We expect a string as the view value since we are using the Slack Block Builder which returns JSON strings 41 | if ('view' in mockedFunctionArgument) { 42 | const mockedApiMethodViewArg = mockedFunction.mock.calls[0][0]; 43 | expect(global.isValidJSON(mockedApiMethodViewArg.view)).toBeTruthy(); 44 | } 45 | } 46 | 47 | // If the callback function calls the ack() method, we expect the ack mock function to be called 48 | if (usesAck) expect(global.ackMockFunc).toHaveBeenCalledTimes(1); 49 | }; 50 | 51 | // A helper function to test if a listener's callback function errors out properly 52 | // The required inputs consist of the listener's callback function (as a promise), and any mocked API method 53 | // that the callback function usually calls. testErrorLog will simulate what happens when that API method call errors out. 54 | // Since not all listeners pass the "ack()" method to their callback functions, 55 | // expecting the ack mock function to be called is conditional 56 | global.testErrorLog = async ( 57 | callbackFunctionPromiseToTest, 58 | methodToFail, 59 | usesAck = true, 60 | ) => { 61 | // Temporarily mock console.error to prevent the error from being logged 62 | const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); 63 | const errorMsg = 'Oh no! We have an error'; 64 | methodToFail.mockRejectedValueOnce(new Error(errorMsg)); 65 | 66 | await callbackFunctionPromiseToTest; 67 | 68 | if (usesAck) expect(global.ackMockFunc).toBeCalledTimes(1); 69 | expect(methodToFail).toBeCalledTimes(1); 70 | expect(errorSpy).toBeCalledTimes(1); 71 | errorSpy.mockRestore(); 72 | }; 73 | -------------------------------------------------------------------------------- /listeners/views/__tests__/new-task-modal.test.js: -------------------------------------------------------------------------------- 1 | const { 2 | modalViewPayloadSelectedDateNoTime, 3 | modalViewPayloadSelectedDateFromPast, 4 | modalViewPayloadSelectedDateAndTime, 5 | modalViewPayloadDueDateTooFarInFuture, 6 | modelViewPayloadTaskAssignedToDifferentUser, 7 | } = require('./__fixtures__/view-fixtures'); 8 | 9 | const { 10 | testView, 11 | testViewAckError, 12 | testViewError, 13 | } = require('./__utils__/view-test-util-funcs'); 14 | 15 | const { newTaskModalCallback } = require('../new-task-modal'); 16 | 17 | describe('New task modal view callback function test ', () => { 18 | it('returns an ack() with an error if date is selected but time is not selected', async () => { 19 | await testViewAckError( 20 | modalViewPayloadSelectedDateNoTime, 21 | newTaskModalCallback, 22 | { 23 | response_action: 'errors', 24 | errors: { 25 | taskDueTime: expect.stringContaining('set a time'), 26 | }, 27 | }, 28 | ); 29 | }); 30 | it('returns an ack() with an error if a due date/time in the past was selected', async () => { 31 | await testViewAckError( 32 | modalViewPayloadSelectedDateFromPast, 33 | newTaskModalCallback, 34 | { 35 | response_action: 'errors', 36 | errors: { 37 | taskDueTime: expect.stringContaining('future'), 38 | taskDueDate: expect.stringContaining('future'), 39 | }, 40 | }, 41 | ); 42 | }); 43 | 44 | // TODO: test for the client.views.publish() method as well since reloadAppHome() is called in the callback 45 | 46 | it('schedules a message to remind the user of an upcoming task if the due date is < 120 days in the future', async () => { 47 | await testView( 48 | modalViewPayloadSelectedDateAndTime, 49 | newTaskModalCallback, 50 | global.chatScheduleMessageMockFunc, 51 | { 52 | channel: modalViewPayloadSelectedDateAndTime.user.id, 53 | post_at: expect.any(Number), // TODO: possibly beef up this test to check for a valid time 54 | text: expect.stringContaining( 55 | modalViewPayloadSelectedDateAndTime.view.state.values.taskTitle 56 | .taskTitle.value, 57 | ), 58 | }, 59 | ); 60 | }); 61 | 62 | it('posts a message to the user if the due date is > 120 days in the future', async () => { 63 | await testView( 64 | modalViewPayloadDueDateTooFarInFuture, 65 | newTaskModalCallback, 66 | global.chatPostMessageMockFunc, 67 | { 68 | text: expect.stringContaining('more than 120 days from'), 69 | channel: modalViewPayloadSelectedDateAndTime.user.id, 70 | }, 71 | ); 72 | }); 73 | 74 | it('sends a message to the user who the task was assigned to if the assignee != task creator', async () => { 75 | const selectedUser = 76 | modelViewPayloadTaskAssignedToDifferentUser.view.state.values 77 | .taskAssignUser.taskAssignUser.selected_user; 78 | await testView( 79 | modelViewPayloadTaskAssignedToDifferentUser, 80 | newTaskModalCallback, 81 | global.chatPostMessageMockFunc, 82 | { 83 | text: expect.stringContaining( 84 | `<@${modalViewPayloadSelectedDateAndTime.user.id}> assigned you`, 85 | ), 86 | channel: selectedUser, 87 | }, 88 | ); 89 | }); 90 | 91 | it('Logs an error when the the new view fails to be published', async () => { 92 | await testViewError( 93 | modalViewPayloadDueDateTooFarInFuture, 94 | newTaskModalCallback, 95 | global.viewPublishMockFunc, 96 | ); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributors Guide 2 | 3 | Interested in contributing? Awesome! Before you do though, please read our [Code of Conduct](https://slackhq.github.io/code-of-conduct). We take it very seriously, and expect that you will as well. 4 | 5 | There are many ways you can contribute! :heart: 6 | 7 | ### Bug Reports and Fixes :bug: 8 | - If you find a bug, please search for it in the [Issues](https://github.com/slackapi/node-tasks-app/issues), and if it isn't already tracked, 9 | [create a new issue](https://github.com/slackapi/node-tasks-app/issues/new). Fill out the "Bug Report" section of the issue template. Even if an Issue is closed, feel free to comment and add details, it will still 10 | be reviewed. 11 | - Issues that have already been identified as a bug (note: able to reproduce) will be labelled `bug`. 12 | - If you'd like to submit a fix for a bug, [send a Pull Request](#creating_a_pull_request) and mention the Issue number. 13 | - Include tests that isolate the bug and verifies that it was fixed. 14 | 15 | ### New Features :bulb: 16 | - If you'd like to add new functionality to this project, describe the problem you want to solve in a [new Issue](https://github.com/slackapi/node-tasks-app/issues/new). 17 | - Issues that have been identified as a feature request will be labelled `enhancement`. 18 | - If you'd like to implement the new feature, please wait for feedback from the project maintainers before spending too much time writing the code. In some cases, `enhancement`s may not align well with the project objectives at the time. 19 | 20 | ### Tests :mag:, Documentation :books:, Miscellaneous :sparkles: 21 | - If you'd like to improve the tests, you want to make the documentation clearer, you have an alternative implementation of something that may have advantages over the way its currently done, or you have any other change, we would be happy to hear about it! 22 | - If its a trivial change, go ahead and [send a Pull Request](#creating_a_pull_request) with the changes you have in mind. 23 | - If not, [open an Issue](https://github.com/slackapi/node-tasks-app/issues/new) to discuss the idea first. 24 | 25 | If you're new to our project and looking for some way to make your first contribution, look for Issues labelled `good first contribution`. 26 | 27 | ## Requirements 28 | 29 | For your contribution to be accepted: 30 | 31 | - [x] You must have signed the [Contributor License Agreement (CLA)](https://cla.salesforce.com/sign-cla). 32 | - [x] The test suite must be complete and pass. 33 | - [x] The changes must be approved by code review. 34 | - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. 35 | 36 | If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created. 37 | 38 | [Interested in knowing more about about pull requests at Slack?](https://slack.engineering/on-empathy-pull-requests-979e4257d158#.awxtvmb2z) 39 | 40 | ## Creating a Pull Request 41 | 42 | 1. :fork_and_knife: Fork the repository on GitHub. 43 | 2. :runner: Clone/fetch your fork to your local development machine. It's a good idea to run the tests just 44 | to make sure everything is in order. 45 | 3. :herb: Create a new branch and check it out. 46 | 4. :crystal_ball: Make your changes and commit them locally. Magic happens here! 47 | 5. :arrow_heading_up: Push your new branch to your fork. (e.g. `git push username fix-issue-16`). 48 | 6. :inbox_tray: Open a Pull Request on github.com from your new branch on your fork to `main` in this 49 | repository. 50 | -------------------------------------------------------------------------------- /listeners/actions/__tests__/__fixtures__/action-fixtures.js: -------------------------------------------------------------------------------- 1 | /* --------------------------------------------- Hard-coded payload data -------------------------------------------- */ 2 | // TODO: Cleanup the payloads,keep only whats needed 3 | 4 | const appHomeBlockChecklistSelectionActionPayloadBase = ( 5 | actions, 6 | additionalProperies, 7 | ) => ({ 8 | ...additionalProperies, 9 | actions, 10 | type: 'block_actions', 11 | team: { 12 | id: 'T9TK3CUKW', 13 | domain: 'example', 14 | }, 15 | user: { 16 | id: 'UA8RXUSPL', 17 | username: 'jtorrance', 18 | name: 'jtorrance', 19 | team_id: 'T9TK3CUKW', 20 | }, 21 | api_app_id: 'AABA1ABCD', 22 | token: '9s8d9as89d8as9d8as989', 23 | container: { 24 | type: 'view', 25 | view_id: 'V0PKB1ZFV', 26 | }, 27 | trigger_id: '24571818370.22717085937.b9c7ca14b87be6b44ff5864edba8306f', 28 | view: { 29 | id: 'V0PKB1ZFV', 30 | team_id: 'T9TK3CUKW', 31 | type: 'home', 32 | blocks: [ 33 | { 34 | type: 'section', 35 | block_id: '8ZG', 36 | text: { 37 | type: 'mrkdwn', 38 | text: 'A stack of blocks for the simple sample Block Kit Home tab.', 39 | verbatim: false, 40 | }, 41 | }, 42 | { 43 | type: 'actions', 44 | block_id: '7fhg', 45 | elements: [ 46 | { 47 | type: 'button', 48 | action_id: 'XRX', 49 | text: { 50 | type: 'plain_text', 51 | text: 'Action A', 52 | emoji: true, 53 | }, 54 | }, 55 | { 56 | type: 'button', 57 | action_id: 'GFBew', 58 | text: { 59 | type: 'plain_text', 60 | text: 'Action B', 61 | emoji: true, 62 | }, 63 | }, 64 | ], 65 | }, 66 | { 67 | type: 'section', 68 | block_id: '6evU', 69 | text: { 70 | type: 'mrkdwn', 71 | text: "And now it's slightly more complex.", 72 | verbatim: false, 73 | }, 74 | }, 75 | ], 76 | private_metadata: '', 77 | callback_id: '', 78 | state: { 79 | values: {}, 80 | }, 81 | hash: '1571318366.2468e46f', 82 | clear_on_close: false, 83 | notify_on_close: false, 84 | close: null, 85 | submit: null, 86 | previous_view_id: null, 87 | root_view_id: 'V0PKB1ZFV', 88 | app_id: 'AABA1ABCD', 89 | external_id: '', 90 | app_installed_team_id: 'T9TK3CUKW', 91 | bot_id: 'B0B00B00', 92 | }, 93 | }); 94 | 95 | const appHomeBlockChecklistSelectionActionPayload = 96 | appHomeBlockChecklistSelectionActionPayloadBase([ 97 | { 98 | selected_options: [ 99 | { 100 | text: { 101 | type: 'mrkdwn', 102 | text: '*example task here*', 103 | verbatim: false, 104 | }, 105 | value: 'open-task-2', 106 | }, 107 | ], 108 | action_id: 'blockOpenTaskCheckboxClicked', 109 | block_id: 'open-task-status-change-0', 110 | type: 'checkboxes', 111 | action_ts: '1627533904.067682', 112 | }, 113 | ]); 114 | 115 | const messageBlockActionPayload = 116 | appHomeBlockChecklistSelectionActionPayloadBase( 117 | [ 118 | { 119 | action_id: 'WaXA', 120 | block_id: '=qXel', 121 | text: { 122 | type: 'plain_text', 123 | text: 'View', 124 | emoji: true, 125 | }, 126 | value: '0', 127 | type: 'button', 128 | action_ts: '1548426417.840180', 129 | }, 130 | ], 131 | { 132 | message: { 133 | bot_id: 'BAH5CA16Z', 134 | type: 'message', 135 | text: "This content can't be displayed.", 136 | user: 'UAJ2RU415', 137 | ts: '1548261231.000200', 138 | }, 139 | }, 140 | ); 141 | 142 | const buttonPressActionPayload = 143 | appHomeBlockChecklistSelectionActionPayloadBase([ 144 | { 145 | action_id: 'reopen-task', 146 | block_id: 'bByD', 147 | text: { type: 'plain_text', text: 'Reopen', emoji: true }, 148 | value: '1', 149 | type: 'button', 150 | action_ts: '1627534494.853645', 151 | }, 152 | ]); 153 | 154 | module.exports = { 155 | appHomeBlockChecklistSelectionActionPayload, 156 | messageBlockActionPayload, 157 | buttonPressActionPayload, 158 | }; 159 | -------------------------------------------------------------------------------- /user-interface/modals/__tests__/new-task.test.js: -------------------------------------------------------------------------------- 1 | const { newTask } = require('../index'); 2 | 3 | test('Returns blocks for the new task modal if no prefill is provided', () => { 4 | const testUserID = 'U123456'; 5 | const expected = { 6 | title: { 7 | type: 'plain_text', 8 | text: 'Create new task', 9 | }, 10 | submit: { 11 | type: 'plain_text', 12 | text: 'Create', 13 | }, 14 | callback_id: 'new-task-modal', 15 | blocks: [ 16 | { 17 | label: { 18 | type: 'plain_text', 19 | text: 'New task', 20 | }, 21 | block_id: 'taskTitle', 22 | element: { 23 | placeholder: { 24 | type: 'plain_text', 25 | text: 'Do this thing', 26 | }, 27 | action_id: 'taskTitle', 28 | type: 'plain_text_input', 29 | }, 30 | type: 'input', 31 | }, 32 | { 33 | label: { 34 | type: 'plain_text', 35 | text: 'Assign user', 36 | }, 37 | block_id: 'taskAssignUser', 38 | element: { 39 | action_id: 'taskAssignUser', 40 | initial_user: `${testUserID}`, 41 | type: 'users_select', 42 | }, 43 | type: 'input', 44 | }, 45 | { 46 | label: { 47 | type: 'plain_text', 48 | text: 'Due date', 49 | }, 50 | block_id: 'taskDueDate', 51 | optional: true, 52 | element: { 53 | action_id: 'taskDueDate', 54 | type: 'datepicker', 55 | }, 56 | type: 'input', 57 | }, 58 | // The timepicker is currently in beta and cannot be used in an App 59 | // that is listed in the App Directory 60 | { 61 | label: { 62 | type: 'plain_text', 63 | text: 'Time', 64 | }, 65 | block_id: 'taskDueTime', 66 | optional: true, 67 | element: { 68 | action_id: 'taskDueTime', 69 | type: 'timepicker', 70 | }, 71 | type: 'input', 72 | }, 73 | ], 74 | type: 'modal', 75 | }; 76 | expect(newTask(null, testUserID)).toBe(JSON.stringify(expected)); 77 | }); 78 | 79 | test('Returns blocks for the new task modal if a prefill is provided', () => { 80 | const taskTitle = 'This is a task'; 81 | const testUserID = 'U123456'; 82 | 83 | const expected = { 84 | title: { 85 | type: 'plain_text', 86 | text: 'Create new task', 87 | }, 88 | submit: { 89 | type: 'plain_text', 90 | text: 'Create', 91 | }, 92 | callback_id: 'new-task-modal', 93 | blocks: [ 94 | { 95 | label: { 96 | type: 'plain_text', 97 | text: 'New task', 98 | }, 99 | block_id: 'taskTitle', 100 | element: { 101 | placeholder: { 102 | type: 'plain_text', 103 | text: 'Do this thing', 104 | }, 105 | action_id: 'taskTitle', 106 | initial_value: `${taskTitle}`, 107 | type: 'plain_text_input', 108 | }, 109 | type: 'input', 110 | }, 111 | { 112 | label: { 113 | type: 'plain_text', 114 | text: 'Assign user', 115 | }, 116 | block_id: 'taskAssignUser', 117 | element: { 118 | action_id: 'taskAssignUser', 119 | initial_user: `${testUserID}`, 120 | type: 'users_select', 121 | }, 122 | type: 'input', 123 | }, 124 | { 125 | label: { 126 | type: 'plain_text', 127 | text: 'Due date', 128 | }, 129 | block_id: 'taskDueDate', 130 | optional: true, 131 | element: { 132 | action_id: 'taskDueDate', 133 | type: 'datepicker', 134 | }, 135 | type: 'input', 136 | }, 137 | // The timepicker is currently in beta and cannot be used in an App 138 | // that is listed in the App Directory 139 | { 140 | label: { 141 | type: 'plain_text', 142 | text: 'Time', 143 | }, 144 | block_id: 'taskDueTime', 145 | optional: true, 146 | element: { 147 | action_id: 'taskDueTime', 148 | type: 'timepicker', 149 | }, 150 | type: 'input', 151 | }, 152 | ], 153 | type: 'modal', 154 | }; 155 | expect(newTask(taskTitle, testUserID)).toBe(JSON.stringify(expected)); 156 | }); 157 | -------------------------------------------------------------------------------- /listeners/views/new-task-modal.js: -------------------------------------------------------------------------------- 1 | const { DateTime } = require('luxon'); 2 | 3 | const { User, Task } = require('../../models'); 4 | const { modals } = require('../../user-interface'); 5 | const { taskReminder } = require('../../user-interface/messages'); 6 | const { reloadAppHome } = require('../../utilities'); 7 | 8 | const newTaskModalCallback = async ({ ack, view, body, client }) => { 9 | const providedValues = view.state.values; 10 | 11 | const taskTitle = providedValues.taskTitle.taskTitle.value; 12 | 13 | const selectedDate = providedValues.taskDueDate.taskDueDate.selected_date; 14 | const selectedTime = providedValues.taskDueTime.taskDueTime.selected_time; 15 | 16 | const selectedUser = 17 | providedValues.taskAssignUser.taskAssignUser.selected_user; 18 | 19 | const task = Task.build({ title: taskTitle }); 20 | 21 | if (selectedDate) { 22 | if (!selectedTime) { 23 | await ack({ 24 | response_action: 'errors', 25 | errors: { 26 | taskDueTime: "Please set a time for the date you've chosen", 27 | }, 28 | }); 29 | return; 30 | } 31 | const taskDueDate = DateTime.fromISO(`${selectedDate}T${selectedTime}`); 32 | const diffInDays = taskDueDate.diffNow('days').toObject().days; 33 | // Task due date is in the past, so reject 34 | if (diffInDays < 0) { 35 | await ack({ 36 | response_action: 'errors', 37 | errors: { 38 | taskDueDate: 'Please select a due date in the future', 39 | taskDueTime: 'Please select a time in the future', 40 | }, 41 | }); 42 | return; 43 | } 44 | task.dueDate = taskDueDate; 45 | } 46 | 47 | try { 48 | // Grab the creating user from the DB 49 | const queryResult = await User.findOrCreate({ 50 | where: { 51 | slackUserID: body.user.id, 52 | slackWorkspaceID: body.team.id, 53 | }, 54 | }); 55 | const user = queryResult[0]; 56 | 57 | // Grab the assignee user from the DB 58 | const querySelectedUser = await User.findOrCreate({ 59 | where: { 60 | slackUserID: selectedUser, 61 | slackWorkspaceID: body.team.id, // TODO better compatibility with Slack Connect. 62 | }, 63 | }); 64 | const selectedUserObject = querySelectedUser[0]; 65 | 66 | // Persist what we know about the task so far 67 | await task.save(); 68 | await task.setCreator(user); 69 | await task.setCurrentAssignee(selectedUserObject); 70 | 71 | if (task.dueDate) { 72 | const dateObject = DateTime.fromJSDate(task.dueDate); 73 | // The `chat.scheduleMessage` endpoint only accepts messages in the next 120 days, 74 | // so if the date is further than that, don't set a reminder, and let the user know. 75 | const assignee = await task.getCurrentAssignee(); 76 | if (dateObject.diffNow('days').toObject().days < 120) { 77 | await client.chat 78 | .scheduleMessage( 79 | taskReminder( 80 | dateObject.toSeconds(), 81 | assignee.slackUserID, 82 | task.title, 83 | dateObject.toRelativeCalendar(), 84 | task.id, 85 | ), 86 | ) 87 | .then(async (response) => { 88 | task.scheduledMessageId = response.scheduled_message_id; 89 | await task.save(); 90 | }); 91 | } else { 92 | // TODO better error message and store it in /user-interface 93 | await client.chat.postMessage({ 94 | text: `Sorry, but we couldn't set a reminder for ${taskTitle}, as it's more than 120 days from now`, 95 | channel: assignee.slackUserID, 96 | }); 97 | } 98 | } 99 | await task.save(); 100 | await ack({ 101 | response_action: 'update', 102 | view: modals.taskCreated(taskTitle), 103 | }); 104 | if (selectedUser !== body.user.id) { 105 | await client.chat.postMessage({ 106 | channel: selectedUser, 107 | text: `<@${body.user.id}> assigned you a new task:\n- *${taskTitle}*`, 108 | }); 109 | await reloadAppHome(client, selectedUser, body.team.id); 110 | } 111 | 112 | await reloadAppHome(client, body.user.id, body.team.id); 113 | } catch (error) { 114 | await ack({ 115 | response_action: 'update', 116 | view: modals.taskCreationError(taskTitle), 117 | }); 118 | // eslint-disable-next-line no-console 119 | console.error(error); 120 | } 121 | }; 122 | 123 | module.exports = { newTaskModalCallback }; 124 | -------------------------------------------------------------------------------- /listeners/views/__tests__/__fixtures__/view-fixtures.js: -------------------------------------------------------------------------------- 1 | const { DateTime } = require('luxon'); 2 | 3 | /* --------------------------------------------- Hard-coded payload data -------------------------------------------- */ 4 | // TODO: Cleanup the payloads,keep only whats needed 5 | 6 | const viewPayloadBase = ( 7 | selectedDate = null, 8 | selectedTime = null, 9 | selectedUser = 'U01561Q291S', 10 | additionalProperties = {}, 11 | ) => ({ 12 | ...additionalProperties, 13 | type: 'view_submission', 14 | team: { id: 'T014K402SOW', domain: 'testteamdomain' }, 15 | user: { 16 | id: 'U01561Q291S', 17 | username: 'tester', 18 | name: 'tester', 19 | team_id: 'T014K402SOW', 20 | }, 21 | api_app_id: 'A029RUYCM3J', 22 | token: 'kD8hEXkgKQSAxJIoDbIdhwG2', 23 | trigger_id: '2324340212661.1155136084743.396a925caf7c4a07e29778dbd7b577ad', 24 | view: { 25 | id: 'V029MB07XS6', 26 | team_id: 'T014K402SOW', 27 | type: 'modal', 28 | blocks: [ 29 | { 30 | type: 'input', 31 | block_id: 'taskTitle', 32 | label: { type: 'plain_text', text: 'New task', emoji: true }, 33 | optional: false, 34 | dispatch_action: false, 35 | element: { 36 | type: 'plain_text_input', 37 | action_id: 'taskTitle', 38 | placeholder: { 39 | type: 'plain_text', 40 | text: 'Do this thing', 41 | emoji: true, 42 | }, 43 | dispatch_action_config: { trigger_actions_on: ['on_enter_pressed'] }, 44 | }, 45 | }, 46 | { 47 | type: 'input', 48 | block_id: 'taskAssignUser', 49 | label: { type: 'plain_text', text: 'Assign user', emoji: true }, 50 | optional: false, 51 | dispatch_action: false, 52 | element: { 53 | type: 'users_select', 54 | action_id: 'taskAssignUser', 55 | initial_user: 'U01561Q291S', 56 | }, 57 | }, 58 | { 59 | type: 'input', 60 | block_id: 'taskDueDate', 61 | label: { type: 'plain_text', text: 'Due date', emoji: true }, 62 | optional: true, 63 | dispatch_action: false, 64 | element: { type: 'datepicker', action_id: 'taskDueDate' }, 65 | }, 66 | { 67 | type: 'input', 68 | block_id: 'taskDueTime', 69 | label: { type: 'plain_text', text: 'Time', emoji: true }, 70 | optional: true, 71 | dispatch_action: false, 72 | element: { type: 'timepicker', action_id: 'taskDueTime' }, 73 | }, 74 | ], 75 | private_metadata: '', 76 | callback_id: 'new-task-modal', 77 | state: { 78 | values: { 79 | taskTitle: { 80 | taskTitle: { 81 | type: 'plain_text_input', 82 | value: 'date and time not selected', 83 | }, 84 | }, 85 | taskAssignUser: { 86 | taskAssignUser: { 87 | type: 'users_select', 88 | selected_user: selectedUser, 89 | }, 90 | }, 91 | taskDueDate: { 92 | taskDueDate: { type: 'datepicker', selected_date: selectedDate }, 93 | }, 94 | taskDueTime: { 95 | taskDueTime: { type: 'timepicker', selected_time: selectedTime }, 96 | }, 97 | }, 98 | }, 99 | hash: '1627582704.kjn7sBlV', 100 | title: { type: 'plain_text', text: 'Create new task', emoji: true }, 101 | clear_on_close: false, 102 | notify_on_close: false, 103 | close: null, 104 | submit: { type: 'plain_text', text: 'Create', emoji: true }, 105 | previous_view_id: null, 106 | root_view_id: 'V029MB07XS6', 107 | app_id: 'A029RUYCM3J', 108 | external_id: '', 109 | app_installed_team_id: 'T014K402SOW', 110 | bot_id: 'B028VG2GJJJ', 111 | }, 112 | response_urls: [], 113 | is_enterprise_install: false, 114 | enterprise: null, 115 | }); 116 | 117 | const validDate = DateTime.now().plus({ months: 1, days: 1 }).toISODate(); 118 | 119 | const pastDate = DateTime.now().minus({ months: 1, days: 1 }).toISODate(); 120 | 121 | const dateTooFarInFuture = DateTime.now() 122 | .plus({ months: 11, days: 30 }) 123 | .toISODate(); 124 | 125 | const modalViewPayloadSelectedDateNoTime = viewPayloadBase(validDate); 126 | 127 | const modalViewPayloadSelectedDateFromPast = viewPayloadBase(pastDate, '10:00'); 128 | 129 | const modalViewPayloadSelectedDateAndTime = viewPayloadBase(validDate, '15:00'); 130 | 131 | const modalViewPayloadDueDateTooFarInFuture = viewPayloadBase( 132 | dateTooFarInFuture, 133 | '13:00', 134 | ); 135 | 136 | const modelViewPayloadTaskAssignedToDifferentUser = viewPayloadBase( 137 | validDate, 138 | '15:00', 139 | 'U014261G301V', 140 | ); 141 | 142 | module.exports = { 143 | modalViewPayloadSelectedDateNoTime, 144 | modalViewPayloadSelectedDateFromPast, 145 | modalViewPayloadSelectedDateAndTime, 146 | modalViewPayloadDueDateTooFarInFuture, 147 | modelViewPayloadTaskAssignedToDifferentUser, 148 | }; 149 | --------------------------------------------------------------------------------