diff --git a/docs/_steps/adding_editing_workflow_step.md b/docs/_steps/adding_editing_workflow_step.md new file mode 100644 index 000000000..acef1f33a --- /dev/null +++ b/docs/_steps/adding_editing_workflow_step.md @@ -0,0 +1,66 @@ +--- +title: Adding or editing workflow steps +lang: en +slug: adding-editing-steps +order: 3 +beta: true +--- + +
+ +When a builder adds (or later edits) your step in their workflow, your app will receive a `workflow_step_edit` action. The callback assigned to the `edit` property of the `WorkflowStep` configuration object passed in during instantiation will run when this action occurs. + +Whether a builder is adding or editing a step, you need to provide them with a special `workflow_step` modal — a workflow step configuration modal — where step-specific settings are chosen. Since the purpose of this modal is tied to a workflow step's configuration, it has more restrictions than typical modals—most notably, you cannot include `title​`, `submit​`, or `close`​ properties in the payload. By default, the `callback_id` used for this modal will be the same as that of the workflow step. + +Within the `edit` callback, the `configure()` utility can be used to easily open your step's configuration modal by passing in an object with your view's `blocks`. To disable configuration save before certain conditions are met, pass in `submit_disabled` with a value of `true`. + +To learn more about workflow step configuration modals, [read the documentation](https://api.slack.com/reference/workflows/configuration-view). + +
+ +```javascript +const ws = new WorkflowStep('add_task', { + edit: async ({ ack, step, configure }) => { + await ack(); + + const blocks = [ + { + 'type': 'input', + 'block_id': 'task_name_input', + 'element': { + 'type': 'plain_text_input', + 'action_id': 'name', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Add a task name' + } + }, + 'label': { + 'type': 'plain_text', + 'text': 'Task name' + } + }, + { + 'type': 'input', + 'block_id': 'task_description_input', + 'element': { + 'type': 'plain_text_input', + 'action_id': 'description', + 'placeholder': { + 'type': 'plain_text', + 'text': 'Add a task description' + } + }, + 'label': { + 'type': 'plain_text', + 'text': 'Task description' + } + }, + ]; + + await configure({ blocks }); + }, + save: async ({ ack, step, update }) => {}, + execute: async ({ step, complete, fail }) => {}, +}); +``` \ No newline at end of file diff --git a/docs/_steps/configuring_workflow_steps.md b/docs/_steps/configuring_workflow_steps.md deleted file mode 100644 index a0ca433fa..000000000 --- a/docs/_steps/configuring_workflow_steps.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -title: Configuring and updating workflow steps -lang: en -slug: configuring-steps -order: 2 -beta: true ---- -{% raw %} -
-When a builder is adding your step to a new or existing workflow, your app will need to configure and update that step: - -**1. Listening for `workflow_step_edit` action** - -When a builder initially adds (or later edits) your step in their workflow, your app will receive a `workflow_step_edit` action. Your app can listen to `workflow_step_edit` using `action()` and the `callback_id` in your app's configuration. - -**2. Opening and listening to configuration modal** - -The `workflow_step_edit` action will contain a `trigger_id` which your app will use to call `views.open` to open a modal of type `workflow_step`. This configuration modal has more restrictions than typical modals—most notably you cannot include `title`, `submit`, or `close` properties in the payload. - -To learn more about configuration modals, [read the documentation](https://api.slack.com/workflows/steps#handle_config_view). - -Similar to other modals, your app can listen to this `view_submission` payload with the built-in [`views()` method](#view_submissions). - -**3. Updating the builder's workflow** - -After your app listens to the `view_submission`, you'll call [`workflows.updateStep`](https://api.slack.com/methods/workflows.updateStep) with the unique `workflow_step_id` (found in the `body`'s `workflow_step` object) to save the configuration for that builder's specific workflow. Two important parameters: -- `inputs` is an object with keyed child objects representing the data your app expects to receive from the user upon workflow step execution. You can include handlebar-style syntax (`{{ variable }}`) for variables that are collected earlier in a workflow. -- `outputs` is an array of objects indicating the data your app will provide upon workflow step completion. - -Read the documentation for [`input` objects](https://api.slack.com/reference/workflows/workflow_step#input) and [`output` objects](https://api.slack.com/reference/workflows/workflow_step#output) to learn more about how to structure these parameters. - -
-{% endraw %} - -```javascript -// Your app will be called when user adds your step to their workflow -app.action({ type: 'workflow_step_edit', callback_id: 'add_task' }, async ({ body, ack, client }) => { - // Acknowledge the event - await ack(); - // Open the configuration modal using `views.open` - await client.views.open({ - trigger_id: body.trigger_id, - view: { - type: 'workflow_step', - // callback_id to listen to view_submission - callback_id: 'add_task_config', - blocks: [ - // Input blocks will allow users to pass variables from a previous step to your's - { 'type': 'input', - 'block_id': 'task_name_input', - 'element': { - 'type': 'plain_text_input', - 'action_id': 'name', - 'placeholder': { - 'type': 'plain_text', - 'text': 'Add a task name' - } - }, - 'label': { - 'type': 'plain_text', - 'text': 'Task name' - } - }, - { 'type': 'input', - 'block_id': 'task_description_input', - 'element': { - 'type': 'plain_text_input', - 'action_id': 'description', - 'placeholder': { - 'type': 'plain_text', - 'text': 'Add a task description' - } - }, - 'label': { - 'type': 'plain_text', - 'text': 'Task description' - } - } - ] - } - }); -}); - -app.view('add_task_config', async ({ ack, view, body, client }) => { - // Acknowledge the submission - await ack(); - // Unique workflow edit ID - let workflowEditId = body.workflow_step.workflow_step_edit_id; - // Input values found in the view's state object - let taskName = view.state.values.task_name_input.name; - let taskDescription = view.state.values.task_description_input.description; - - await client.workflows.updateStep({ - workflow_step_edit_id: workflowEditId, - inputs: { - taskName: { value: (taskName || '') }, - taskDescription: { value: (taskDescription || '') } - }, - outputs: [ - { - name: 'taskName', - type: 'text', - label: 'Task name', - }, - { - name: 'taskDescription', - type: 'text', - label: 'Task description', - } - ] - }); -}); -``` \ No newline at end of file diff --git a/docs/_steps/creating_workflow_step.md b/docs/_steps/creating_workflow_step.md new file mode 100644 index 000000000..55c7a90f9 --- /dev/null +++ b/docs/_steps/creating_workflow_step.md @@ -0,0 +1,31 @@ +--- +title: Creating a workflow step +lang: en +slug: creating-steps +order: 2 +beta: true +--- + +
+ +To create a new workflow step, Bolt provides the `WorkflowStep` class. + +When instantiating a new `WorkflowStep`, pass in the step's `callback_id`, which is defined in your app configuration, and a step configuration object. + +The configuration object for a `WorkflowStep` contains three properties: `edit`, `save`, and `execute`. Each of these properties must either hold a value of a single callback or an array of callbacks. All callbacks have access to a `step` object that contains information about the workflow step event, as well as one or more utility functions. + +After instantiating your workflow step, pass it the instance into `app.step()`. Behind the scenes, your app will listen and respond to the workflow step’s events using the callbacks provided in the configuration object. + +
+ +```javascript +const { WorkflowStep } = require('@slack/bolt'); + +const ws = new WorkflowStep('add_task', { + edit: async ({ ack, step, configure }) => {}, + save: async ({ ack, step, update }) => {}, + execute: async ({ step, complete, fail }) => {}, +}); + +app.step(ws); +``` \ No newline at end of file diff --git a/docs/_steps/executing_workflow_steps.md b/docs/_steps/executing_workflow_steps.md index 0167010ce..5bd96e6b9 100644 --- a/docs/_steps/executing_workflow_steps.md +++ b/docs/_steps/executing_workflow_steps.md @@ -2,30 +2,37 @@ title: Executing workflow steps lang: en slug: executing-steps -order: 3 +order: 5 beta: true ---
-When your workflow is executed by an end user, your app will receive a `workflow_step_execute` event. This event includes the user's `inputs` and a unique workflow execution ID. Your app must either call [`workflows.stepCompleted`](https://api.slack.com/methods/workflows.stepCompleted) with the `outputs` you specified in `workflows.updateStep`, or [`workflows.stepFailed`](https://api.slack.com/methods/workflows.stepFailed) to indicate the step failed. +When your workflow step is executed by an end user, your app will receive a `workflow_step_execute` event. The method assigned to the `execute` property of the `WorkflowStep` configuration object passed in during instantiation will run when this event occurs. + +Using the `inputs` from the configuration modal submission in the `save` callback, this is where we make third-party API calls, save things to a database, update the end user's Home Tab, and/or decide what outputs will be available to subsequent workflow steps by mapping values to the `outputs` object. + +Within the `execute` callback, your app must either call `complete()` to indicate that the step's execution was successful, or `fail()` to indicate that the step's execution failed. +
```javascript -app.event('workflow_step_execute', async ({ event, client }) => { - // Unique workflow edit ID - let workflowExecuteId = event.workflow_step.workflow_step_execute_id; - let inputs = event.workflow_step.inputs; - - await client.workflows.stepCompleted({ - workflow_step_execute_id: workflowExecuteId, - outputs: { - taskName: inputs.taskName.value, - taskDescription: inputs.taskDescription.value - } - }); - - // You can do anything else you want here. Some ideas: - // Display results on the user's home tab, update your database, or send a message into a channel +const ws = new WorkflowStep('add_task', { + edit: async ({ ack, step, configure }) => {}, + save: async ({ ack, step, update }) => {}, + execute: async ({ step, complete, fail }) => { + const { inputs } = step; + + const outputs = { + taskName: inputs.taskName.value.value, + taskDescription: inputs.taskDescription.value.value, + }; + + // if everything was successful + await complete({ outputs }); + + // if something went wrong + // fail({ error: { message: "Just testing step failure!" } }); + }, }); ``` \ No newline at end of file diff --git a/docs/_steps/saving_workflow_step.md b/docs/_steps/saving_workflow_step.md new file mode 100644 index 000000000..5a137adbe --- /dev/null +++ b/docs/_steps/saving_workflow_step.md @@ -0,0 +1,59 @@ +--- +title: Saving the step configuration +lang: en +slug: saving-steps +order: 4 +beta: true +--- + +
+ +When the workflow step's configuration has been saved (using the step configuration modal from the `edit` callback), your app will listen for the `view_submission` event. The method assigned to the `save` property of the `WorkflowStep` configuration object passed in during instantiation will run when this event occurs. + +Once the configuration for the workflow step has been determined, builders often use that configuration to craft the custom outputs and behavior that occurs when the end user executes the step. + +Within the `save` callback, the `update()` method can be used to save the builder's step configuration by passing in the following arguments: `inputs`, `outputs`, `step_name` and `step_image_url`. + +`inputs` is an object representing the data your app expects to receive from the user upon workflow step execution. To use variables that were collected earlier in the workflow, you can include handlebar-style syntax (`{{ variable }}`). During the workflow step's execution, those variables will be replaced with their actual runtime value. + +`outputs` is an array of objects containing data that your app will provide upon the workflow step's completion. Outputs can then be used in subsequent steps of the workflow. + +`step_name` and `step_image_url` are available for a more customized look and feel of your workflow step. + +To learn more about how to structure these parameters, [read the documentation](https://api.slack.com/reference/workflows/workflow_step). + +
+ +```javascript +const ws = new WorkflowStep('add_task', { + edit: async ({ ack, step, configure }) => {}, + save: async ({ ack, step, update }) => { + await ack(); + + const { values } = view.state; + const taskName = values.task_name_input.name; + const taskDescription = values.task_description_input.description; + + const inputs = { + taskName: { value: taskName }, + taskDescription: { value: taskDescription } + }; + + const outputs = [ + { + type: 'text', + name: 'taskName', + label: 'Task name', + }, + { + type: 'text', + name: 'taskDescription', + label: 'Task description', + } + ]; + + await update({ inputs, outputs }); + }, + execute: async ({ step, complete, fail }) => {}, +}); +``` \ No newline at end of file diff --git a/docs/_steps/workflow_steps_beta.md b/docs/_steps/workflow_steps_beta.md index 63968f62e..991c9e189 100644 --- a/docs/_steps/workflow_steps_beta.md +++ b/docs/_steps/workflow_steps_beta.md @@ -7,7 +7,9 @@ beta: true ---
-⚠️ Workflow [steps from apps](https://api.slack.com/workflows/steps) is a beta feature. As the feature is developed, **Bolt for JavaScript's API will change to add better native support.** To develop with workflow steps in Bolt, use the `@slack/bolt@feat-workflow-steps` version of the package rather than the standard `@slack/bolt`. +Workflow Steps from apps allow your app to create and process custom workflow steps that users can add using [Workflow Builder](https://api.slack.com/workflows). -The [API documentation](https://api.slack.com/workflows/steps) includes more information on setting up a beta app. +A workflow step is made up of three distinct user events: workflow builders adding or editing the step, saving or updating the step's configuration, and the end user's execution of the step. All three events must be handled for a workflow step to function. + +The [API documentation](https://api.slack.com/workflows/steps) includes more information on setting up your app.
diff --git a/package.json b/package.json index defee440b..9dc361cfb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slack/bolt", - "version": "2.2.3-workflowStepsBeta.1", + "version": "2.3.0-workflowStepsBeta.1", "description": "A framework for building Slack apps, fast.", "author": "Slack Technologies, Inc.", "license": "MIT", diff --git a/src/App.ts b/src/App.ts index 486351e21..b9f1a5903 100644 --- a/src/App.ts +++ b/src/App.ts @@ -21,6 +21,7 @@ import { } from './middleware/builtin'; import { processMiddleware } from './middleware/process'; import { ConversationStore, conversationContext, MemoryStore } from './conversation-store'; +import { WorkflowStep } from './WorkflowStep'; import { Middleware, AnyMiddlewareArgs, @@ -311,6 +312,17 @@ export default class App { return this; } + /** + * Register WorkflowStep middleware + * + * @param workflowStep global workflow step middleware function + */ + public step(workflowStep: WorkflowStep): this { + const m = workflowStep.getMiddleware(); + this.middleware.push(m); + return this; + } + /** * Convenience method to call start on the receiver * diff --git a/src/WorkflowStep.spec.ts b/src/WorkflowStep.spec.ts new file mode 100644 index 000000000..51020a9aa --- /dev/null +++ b/src/WorkflowStep.spec.ts @@ -0,0 +1,241 @@ +import 'mocha'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import rewiremock from 'rewiremock'; +import { + WorkflowStep, + SlackWorkflowStepMiddlewareArgs, + AllWorkflowStepMiddlewareArgs, + WorkflowStepMiddleware, + WorkflowStepOptions, +} from './WorkflowStep'; +import { Override } from './test-helpers'; +import { AllMiddlewareArgs, AnyMiddlewareArgs, WorkflowStepEdit, Middleware } from './types'; +import { WorkflowStepInitializationError } from './errors'; + +async function importWorkflowStep(overrides: Override = {}): Promise { + return rewiremock.module(() => import('./WorkflowStep'), overrides); +} + +const MOCK_FN = async () => { + return; +}; + +const MOCK_CONFIG_SINGLE = { + edit: MOCK_FN, + save: MOCK_FN, + execute: MOCK_FN, +}; + +const MOCK_CONFIG_MULTIPLE = { + edit: [MOCK_FN, MOCK_FN], + save: [MOCK_FN], + execute: [MOCK_FN, MOCK_FN, MOCK_FN], +}; + +describe('WorkflowStep', () => { + describe('constructor', () => { + it('should accept config as single functions', async () => { + const ws = new WorkflowStep('test_callback_id', MOCK_CONFIG_SINGLE); + assert.isNotNull(ws); + }); + + it('should accept config as multiple functions', async () => { + const ws = new WorkflowStep('test_callback_id', MOCK_CONFIG_MULTIPLE); + assert.isNotNull(ws); + }); + }); + + describe('validate', () => { + it('should throw an error if callback_id is not valid', async () => { + const { validate } = await importWorkflowStep(); + + // intentionally casting to string to trigger failure + const badId = {} as string; + const validationFn = () => validate(badId, MOCK_CONFIG_SINGLE); + + const expectedMsg = 'WorkflowStep expects a callback_id as the first argument'; + assert.throws(validationFn, WorkflowStepInitializationError, expectedMsg); + }); + + it('should throw an error if required keys are missing', async () => { + const { validate } = await importWorkflowStep(); + + // intentionally casting to WorkflowStepOptions to trigger failure + const badConfig = ({ + edit: async () => {}, + } as unknown) as WorkflowStepOptions; + + const validationFn = () => validate('callback_id', badConfig); + const expectedMsg = 'WorkflowStep is missing required keys: save, execute'; + assert.throws(validationFn, WorkflowStepInitializationError, expectedMsg); + }); + + it('should throw an error if lifecycle props are not a single callback or an array of callbacks', async () => { + const { validate } = await importWorkflowStep(); + + // intentionally casting to WorkflowStepOptions to trigger failure + const badConfig = ({ + edit: async () => {}, + save: {}, + execute: async () => {}, + } as unknown) as WorkflowStepOptions; + + const validationFn = () => validate('callback_id', badConfig); + const expectedMsg = 'WorkflowStep save property must be a function or an array of functions'; + assert.throws(validationFn, WorkflowStepInitializationError, expectedMsg); + }); + }); + + describe('isStepEvent', () => { + it('should return true if recognized workflow step payload type', async () => { + const fakeEditArgs = (createFakeStepEditAction() as unknown) as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; + const fakeViewArgs = (createFakeStepViewEvent() as unknown) as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; + const fakeExecuteArgs = (createFakeStepExecuteEvent() as unknown) as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; + + const { isStepEvent } = await importWorkflowStep(); + + const editIsStepEvent = isStepEvent(fakeEditArgs); + const viewIsStepEvent = isStepEvent(fakeViewArgs); + const executeIsStepEvent = isStepEvent(fakeExecuteArgs); + + assert.isTrue(editIsStepEvent); + assert.isTrue(viewIsStepEvent); + assert.isTrue(executeIsStepEvent); + }); + + it('should return false if not a recognized workflow step payload type', async () => { + const fakeEditArgs = (createFakeStepEditAction() as unknown) as AnyMiddlewareArgs; + fakeEditArgs.payload.type = 'invalid_type'; + + const { isStepEvent } = await importWorkflowStep(); + const actionIsStepEvent = isStepEvent(fakeEditArgs); + + assert.isFalse(actionIsStepEvent); + }); + }); + + describe('prepareStepArgs', () => { + it('should remove next() from all original event args', async () => { + const fakeEditArgs = (createFakeStepEditAction() as unknown) as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; + const fakeViewArgs = (createFakeStepViewEvent() as unknown) as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; + const fakeExecuteArgs = (createFakeStepExecuteEvent() as unknown) as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; + + const { prepareStepArgs } = await importWorkflowStep(); + + const editStepArgs = prepareStepArgs(fakeEditArgs); + const viewStepArgs = prepareStepArgs(fakeViewArgs); + const executeStepArgs = prepareStepArgs(fakeExecuteArgs); + + assert.notExists(editStepArgs.next); + assert.notExists(viewStepArgs.next); + assert.notExists(executeStepArgs.next); + }); + + it('should augment workflow_step_edit args with step and configure()', async () => { + const fakeArgs = (createFakeStepEditAction() as unknown) as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; + const { prepareStepArgs } = await importWorkflowStep(); + const stepArgs = prepareStepArgs(fakeArgs); + + assert.exists(stepArgs.step); + assert.exists(stepArgs.configure); + }); + + it('should augment view_submission with step and update()', async () => { + const fakeArgs = (createFakeStepViewEvent() as unknown) as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; + const { prepareStepArgs } = await importWorkflowStep(); + const stepArgs = prepareStepArgs(fakeArgs); + + assert.exists(stepArgs.step); + assert.exists(stepArgs.update); + }); + + it('should augment workflow_step_execute with step, complete() and fail()', async () => { + const fakeArgs = (createFakeStepExecuteEvent() as unknown) as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; + const { prepareStepArgs } = await importWorkflowStep(); + const stepArgs = prepareStepArgs(fakeArgs); + + assert.exists(stepArgs.step); + assert.exists(stepArgs.complete); + assert.exists(stepArgs.fail); + }); + }); + + describe('processStepMiddleware', () => { + it('should call each callback in user-provided middleware', async () => { + const { next, ...fakeArgs } = (createFakeStepEditAction() as unknown) as AllWorkflowStepMiddlewareArgs; + const { processStepMiddleware } = await importWorkflowStep(); + + const fn1 = sinon.spy((async ({ next }) => { + await next!(); + }) as Middleware); + const fn2 = sinon.spy(async () => {}); + const fakeMiddleware = [fn1, fn2] as WorkflowStepMiddleware; + + await processStepMiddleware(fakeArgs, fakeMiddleware); + + assert(fn1.called); + assert(fn2.called); + }); + }); +}); + +function createFakeStepEditAction() { + return { + body: { + callback_id: 'foo', + trigger_id: 'bar', + }, + payload: { + type: 'workflow_step_edit', + }, + action: { + workflow_step: {}, + }, + context: {}, + next: sinon.fake(), + }; +} + +function createFakeStepViewEvent() { + return { + body: { + callback_id: 'foo', + trigger_id: 'bar', + workflow_step: { + workflow_step_edit_id: '', + }, + }, + payload: { + type: 'workflow_step', + }, + context: {}, + next: sinon.fake(), + }; +} + +function createFakeStepExecuteEvent() { + return { + body: { + callback_id: 'foo', + trigger_id: 'bar', + }, + event: { + workflow_step: {}, + }, + payload: { + type: 'workflow_step_execute', + workflow_step: { + workflow_step_execute_id: '', + }, + }, + context: {}, + next: sinon.fake(), + }; +} diff --git a/src/WorkflowStep.ts b/src/WorkflowStep.ts new file mode 100644 index 000000000..6c555ea71 --- /dev/null +++ b/src/WorkflowStep.ts @@ -0,0 +1,371 @@ +import { WebAPICallResult, KnownBlock, Block } from '@slack/web-api'; +import { + Middleware, + AllMiddlewareArgs, + AnyMiddlewareArgs, + SlackActionMiddlewareArgs, + SlackViewMiddlewareArgs, + WorkflowStepEdit, + Context, + SlackEventMiddlewareArgs, + ViewWorkflowStepSubmitAction, + WorkflowStepExecuteEvent, +} from './types'; +import { processMiddleware } from './middleware/process'; +import { WorkflowStepInitializationError } from './errors'; + +/** Interfaces */ + +export interface StepConfigureArguments { + blocks: (KnownBlock | Block)[]; + private_metadata?: string; + submit_disabled?: boolean; + external_id?: string; +} + +export interface StepUpdateArguments { + inputs?: {}; + outputs?: []; + step_name?: string; + step_image_url?: string; +} + +export interface StepCompleteArguments { + inputs?: { + [key: string]: { + value: string; + }; + }; + outputs?: { + type: string; + name: string; + label: string; + }[]; +} + +export interface StepFailArguments { + error: { + message: string; + }; +} + +export interface StepConfigureFn { + (config: StepConfigureArguments): Promise; +} + +export interface StepUpdateFn { + (config: StepUpdateArguments): Promise; +} + +export interface StepCompleteFn { + (config: StepCompleteArguments): Promise; +} + +export interface StepFailFn { + (config: StepFailArguments): Promise; +} + +export interface WorkflowStepOptions { + edit: WorkflowStepEditMiddleware | WorkflowStepEditMiddleware[]; + save: WorkflowStepSaveMiddleware | WorkflowStepSaveMiddleware[]; + execute: WorkflowStepExecuteMiddleware | WorkflowStepExecuteMiddleware[]; +} + +/** Types */ + +export type SlackWorkflowStepMiddlewareArgs = + | SlackActionMiddlewareArgs + | SlackViewMiddlewareArgs + | SlackEventMiddlewareArgs<'workflow_step_execute'>; + +export type WorkflowStepEditMiddleware = Middleware>; +export type WorkflowStepSaveMiddleware = Middleware>; +export type WorkflowStepExecuteMiddleware = Middleware>; + +export type WorkflowStepMiddleware = + | WorkflowStepEditMiddleware[] + | WorkflowStepSaveMiddleware[] + | WorkflowStepExecuteMiddleware[]; + +export type AllWorkflowStepMiddlewareArgs< + T extends SlackWorkflowStepMiddlewareArgs = SlackWorkflowStepMiddlewareArgs +> = T & + AllMiddlewareArgs & { + step: T extends SlackActionMiddlewareArgs + ? WorkflowStepEdit['workflow_step'] + : T extends SlackViewMiddlewareArgs + ? ViewWorkflowStepSubmitAction['workflow_step'] + : WorkflowStepExecuteEvent['workflow_step']; + configure?: StepConfigureFn; + update?: StepUpdateFn; + complete?: StepCompleteFn; + fail?: StepFailFn; + }; + +/** Constants */ + +const VALID_PAYLOAD_TYPES = new Set(['workflow_step_edit', 'workflow_step', 'workflow_step_execute']); + +/** Class */ + +export class WorkflowStep { + /** Step callback_id */ + private callbackId: string; + + /** Step Add/Edit :: 'workflow_step_edit' action */ + private edit: WorkflowStepEditMiddleware[]; + + /** Step Config Save :: 'view_submission' */ + private save: WorkflowStepSaveMiddleware[]; + + /** Step Executed/Run :: 'workflow_step_execute' event */ + private execute: WorkflowStepExecuteMiddleware[]; + + constructor(callbackId: string, config: WorkflowStepOptions) { + validate(callbackId, config); + + const { save, edit, execute } = config; + + this.callbackId = callbackId; + this.save = Array.isArray(save) ? save : [save]; + this.edit = Array.isArray(edit) ? edit : [edit]; + this.execute = Array.isArray(execute) ? execute : [execute]; + } + + public getMiddleware(): Middleware { + return async (args): Promise => { + if (isStepEvent(args) && this.matchesConstraints(args)) { + return this.processEvent(args); + } + return args.next!(); + }; + } + + private matchesConstraints(args: SlackWorkflowStepMiddlewareArgs): boolean { + return args.payload.callback_id === this.callbackId; + } + + private async processEvent(args: AllWorkflowStepMiddlewareArgs): Promise { + const { payload } = args; + const stepArgs = prepareStepArgs(args); + const stepMiddleware = this.getStepMiddleware(payload); + return processStepMiddleware(stepArgs, stepMiddleware); + } + + private getStepMiddleware(payload: AllWorkflowStepMiddlewareArgs['payload']): WorkflowStepMiddleware { + switch (payload.type) { + case 'workflow_step_edit': + return this.edit; + case 'workflow_step': + return this.save; + case 'workflow_step_execute': + return this.execute; + default: + return []; + } + } +} + +/** Helper Functions */ + +export function validate(callbackId: string, config: WorkflowStepOptions): void { + // Ensure callbackId is valid + if (typeof callbackId !== 'string') { + const errorMsg = 'WorkflowStep expects a callback_id as the first argument'; + throw new WorkflowStepInitializationError(errorMsg); + } + + // Ensure step config object is passed in + if (typeof config !== 'object') { + const errorMsg = 'WorkflowStep expects a configuration object as the second argument'; + throw new WorkflowStepInitializationError(errorMsg); + } + + // Check for missing required keys + const requiredKeys: (keyof WorkflowStepOptions)[] = ['save', 'edit', 'execute']; + const missingKeys: (keyof WorkflowStepOptions)[] = []; + requiredKeys.forEach((key) => { + if (config[key] === undefined) { + missingKeys.push(key); + } + }); + + if (missingKeys.length > 0) { + const errorMsg = `WorkflowStep is missing required keys: ${missingKeys.join(', ')}`; + throw new WorkflowStepInitializationError(errorMsg); + } + + // Ensure a callback or an array of callbacks is present + const requiredFns: (keyof WorkflowStepOptions)[] = ['save', 'edit', 'execute']; + requiredFns.forEach((fn) => { + if (typeof config[fn] !== 'function' && !Array.isArray(config[fn])) { + const errorMsg = `WorkflowStep ${fn} property must be a function or an array of functions`; + throw new WorkflowStepInitializationError(errorMsg); + } + }); +} + +/** + * `processStepMiddleware()` invokes each callback for lifecycle event + * @param args workflow_step_edit action + */ +export async function processStepMiddleware( + args: AllWorkflowStepMiddlewareArgs, + middleware: WorkflowStepMiddleware, +): Promise { + const { context, client, logger } = args; + // TODO :: revisit type used below (look into contravariance) + const callbacks = [...middleware] as Middleware[]; + const lastCallback = callbacks.pop(); + + if (lastCallback !== undefined) { + await processMiddleware(callbacks, args, context, client, logger, async () => + lastCallback({ ...args, context, client, logger }), + ); + } +} + +export function isStepEvent(args: AnyMiddlewareArgs): args is AllWorkflowStepMiddlewareArgs { + return VALID_PAYLOAD_TYPES.has(args.payload.type); +} + +function selectToken(context: Context): string | undefined { + return context.botToken !== undefined ? context.botToken : context.userToken; +} + +/** + * Factory for `configure()` utility + * @param args workflow_step_edit action + */ +function createStepConfigure( + args: AllWorkflowStepMiddlewareArgs>, +): StepConfigureFn { + const { + context, + client, + body: { callback_id, trigger_id }, + } = args; + const token = selectToken(context); + + return (config: Parameters[0]) => { + return client.views.open({ + token, + trigger_id, + view: { + callback_id, + type: 'workflow_step', + ...config, + }, + }); + }; +} + +/** + * Factory for `update()` utility + * @param args view_submission event + */ +function createStepUpdate( + args: AllWorkflowStepMiddlewareArgs>, +): StepUpdateFn { + const { + context, + client, + body: { + workflow_step: { workflow_step_edit_id }, + }, + } = args; + const token = selectToken(context); + + return (config: Parameters[0] = {}) => { + return client.workflows.updateStep({ + token, + workflow_step_edit_id, + ...config, + }); + }; +} + +/** + * Factory for `complete()` utility + * @param args workflow_step_execute event + */ +function createStepComplete( + args: AllWorkflowStepMiddlewareArgs>, +): StepCompleteFn { + const { + context, + client, + payload: { + workflow_step: { workflow_step_execute_id }, + }, + } = args; + const token = selectToken(context); + + return (config: Parameters[0] = {}) => { + return client.workflows.stepCompleted({ + token, + workflow_step_execute_id, + ...config, + }); + }; +} + +/** + * Factory for `fail()` utility + * @param args workflow_step_execute event + */ +function createStepFail( + args: AllWorkflowStepMiddlewareArgs>, +): StepFailFn { + const { + context, + client, + payload: { + workflow_step: { workflow_step_execute_id }, + }, + } = args; + const token = selectToken(context); + + return (config: Parameters[0]) => { + const { error } = config; + return client.workflows.stepFailed({ + token, + error, + workflow_step_execute_id, + }); + }; +} + +/** + * `prepareStepArgs()` takes in a workflow step's args and: + * 1. removes the next() passed in from App-level middleware processing + * - events will *not* continue down global middleware chain to subsequent listeners + * 2. augments args with step lifecycle-specific properties/utilities + * */ +export function prepareStepArgs( + args: SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs, +): AllWorkflowStepMiddlewareArgs { + const { next, ...stepArgs } = args; + // const preparedArgs: AllWorkflowStepMiddlewareArgs = { ...stepArgs }; + const preparedArgs: any = { ...stepArgs }; // TODO :: remove any + + switch (preparedArgs.payload.type) { + case 'workflow_step_edit': + preparedArgs.step = preparedArgs.action.workflow_step; + preparedArgs.configure = createStepConfigure(preparedArgs); + break; + case 'workflow_step': + preparedArgs.step = preparedArgs.body.workflow_step; + preparedArgs.update = createStepUpdate(preparedArgs); + break; + case 'workflow_step_execute': + preparedArgs.step = preparedArgs.event.workflow_step; + preparedArgs.complete = createStepComplete(preparedArgs); + preparedArgs.fail = createStepFail(preparedArgs); + break; + default: + break; + } + + return preparedArgs; +} diff --git a/src/errors.ts b/src/errors.ts index 9b4ea32d0..d45f7477c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -19,6 +19,8 @@ export enum ErrorCode { * in terms of CodedError. */ UnknownError = 'slack_bolt_unknown_error', + + WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', } export function asCodedError(error: CodedError | Error): CodedError { @@ -93,3 +95,7 @@ export class UnknownError extends Error implements CodedError { this.original = original; } } + +export class WorkflowStepInitializationError extends Error implements CodedError { + public code = ErrorCode.WorkflowStepInitializationError; +} diff --git a/src/index.ts b/src/index.ts index 42fa605a2..78abe3807 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,8 @@ export * from './types'; export { ConversationStore, MemoryStore } from './conversation-store'; +export { WorkflowStep } from './WorkflowStep'; + export { Installation, InstallURLOptions, diff --git a/src/types/actions/index.ts b/src/types/actions/index.ts index 67c6ce536..879969648 100644 --- a/src/types/actions/index.ts +++ b/src/types/actions/index.ts @@ -1,6 +1,7 @@ export * from './block-action'; export * from './interactive-message'; export * from './dialog-action'; +export * from './workflow-step-edit'; import { BlockAction } from './block-action'; import { InteractiveMessage } from './interactive-message'; diff --git a/src/types/middleware.ts b/src/types/middleware.ts index 0e45f7146..23ad74b5b 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -17,7 +17,7 @@ export type AnyMiddlewareArgs = | SlackViewMiddlewareArgs | SlackShortcutMiddlewareArgs; -interface AllMiddlewareArgs { +export interface AllMiddlewareArgs { context: Context; logger: Logger; client: WebClient; diff --git a/src/types/view/index.ts b/src/types/view/index.ts index efa19bef1..336ca1951 100644 --- a/src/types/view/index.ts +++ b/src/types/view/index.ts @@ -5,7 +5,11 @@ import { AckFn } from '../utilities'; /** * Known view action types */ -export type SlackViewAction = ViewSubmitAction | ViewClosedAction; +export type SlackViewAction = + | ViewSubmitAction + | ViewClosedAction + | ViewWorkflowStepSubmitAction + | ViewWorkflowStepClosedAction; // /** * Arguments which listeners and middleware receive to process a view submission event from Slack. @@ -44,11 +48,6 @@ export interface ViewSubmitAction { view: ViewOutput; api_app_id: string; token: string; - workflow_step?: { - workflow_step_edit_id: string; - workflow_id: string; - step_id: string; - }; } /** @@ -73,7 +72,31 @@ export interface ViewClosedAction { api_app_id: string; token: string; is_cleared: boolean; - workflow_step?: { +} + +/** + * A Slack view_submission Workflow Step event + * + * This describes the additional JSON-encoded body details for a step's view_submission event + */ + +export interface ViewWorkflowStepSubmitAction extends ViewSubmitAction { + trigger_id: string; + response_urls: []; + workflow_step: { + workflow_step_edit_id: string; + workflow_id: string; + step_id: string; + }; +} + +/** + * A Slack view_closed Workflow Step event + * + * This describes the additional JSON-encoded body details for a step's view_closed event + */ +export interface ViewWorkflowStepClosedAction extends ViewClosedAction { + workflow_step: { workflow_step_edit_id: string; workflow_id: string; step_id: string;