diff --git a/docs/_basic/listening_modals.md b/docs/_basic/listening_modals.md new file mode 100644 index 000000000..fb16fb216 --- /dev/null +++ b/docs/_basic/listening_modals.md @@ -0,0 +1,55 @@ +--- +title: Listening for view submissions +lang: en +slug: view_submissions +order: 11 +--- + +
+If a view payload contains any input blocks, you must listen to view_submission events to receive their values. To listen to view_submission events, you can use the built-in view() method. + +view() requires a callback_id of type string or RegExp. + +You can access the value of the input blocks by accessing the state object. state contains a values object that uses the block_id and unique action_id to store the input values. + +Read more about view submissions in our API documentation. +
+ +```javascript +// Handle a view_submission event +app.view('view_b', async ({ ack, body, view, context }) => { + // Acknowledge the view_submission event + ack(); + + // Do whatever you want with the input data - here we're saving it to a DB then sending the user a verifcation of their submission + + // Assume there's an input block with `block_1` as the block_id and `input_a` + const val = view['state']['values']['block_1']['input_a']; + const user = body['user']['id']; + + // Message to send user + let msg = ''; + // Save to DB + const results = await db.set(user.input, val); + + if (results) { + // DB save was successful + msg = 'Your submission was successful'; + } else { + msg = 'There was an error with your submission'; + } + + // Message the user + try { + app.client.chat.postMessage({ + token: context.botToken, + channel: user, + text: msg + }); + } + catch (error) { + console.error(error); + } + +}); +``` diff --git a/docs/_basic/listening_responding_options.md b/docs/_basic/listening_responding_options.md index f70c6929e..0099bae6d 100644 --- a/docs/_basic/listening_responding_options.md +++ b/docs/_basic/listening_responding_options.md @@ -2,7 +2,7 @@ title: Listening and responding to options lang: en slug: options -order: 9 +order: 12 ---
diff --git a/docs/_basic/opening_modals.md b/docs/_basic/opening_modals.md new file mode 100644 index 000000000..fc68933e1 --- /dev/null +++ b/docs/_basic/opening_modals.md @@ -0,0 +1,78 @@ +--- +title: Opening modals +lang: en +slug: creating-modals +order: 9 +--- + +
+Modals are focused surfaces that allow you to collect user data and display dynamic information. You can open a modal by passing a valid trigger_id and a view payload to the built-in client's views.open method. + +Your app receives trigger_ids in payloads sent to your Request URL triggered user invocation like a slash command, button press, or interaction with a select menu. + +Read more about modal composition in the API documentation. +
+ +```javascript +// Listen for a slash command invocation +app.command('/ticket', ({ ack, payload, context }) => { + // Acknowledge the command request + ack(); + + try { + const result = app.client.views.open({ + token: context.botToken, + type: 'modal', + // Pass a valid trigger_id within 3 seconds of receiving it + trigger_id: payload.trigger_id, + // View payload + view: { + // View identifier + callback_id: 'view_1', + title: { + type: 'plain_text', + text: 'Modal title' + }, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Welcome to a modal with _blocks_' + }, + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Click me!' + }, + action_id: 'button_abc' + } + }, + { + type: 'input', + block_id: 'input_c', + label: { + type: 'plain_text', + text: 'What are your hopes and dreams?' + }, + element: { + type: 'plain_text_input', + action_id: 'dreamy_input', + multiline: true + } + } + ], + submit: { + type: 'plain_text', + text: 'Submit' + } + } + }); + console.log(result); + } + catch (error) { + console.error(error); + } +}); +``` \ No newline at end of file diff --git a/docs/_basic/updating_pushing_modals.md b/docs/_basic/updating_pushing_modals.md new file mode 100644 index 000000000..8404360dd --- /dev/null +++ b/docs/_basic/updating_pushing_modals.md @@ -0,0 +1,61 @@ +--- +title: Updating and pushing views +lang: en +slug: updating-pushing-views +order: 10 +--- + +
+Modals contain a stack of views. When you call `views.open`, you add the root view to the modal. After the initial call, you can dynamically update a view by calling `views.update`, or stack a new view on top of the root view by calling `views.push`. + +views.update
+To update a view, you can use the built-in client to call views.update with the view_id that was generated when you opened the view, and the updated blocks array. If you're updating the view when a user interacts with an element inside of an existing view, the view_id will be available in the body of the request. + +views.push
+To push a new view onto the view stack, you can use the built-in client to call views.push with a valid trigger_id a new view payload. The arguments for `views.push` is the same as opening modals. After you open a modal, you may only push two additional views onto the view stack. + +Learn more about updating and pushing views in our API documentation. +
+ +```javascript +// Listen for a button invocation with action_id `button_abc` (assume it's inside of a modal) +app.action('button_abc', ({ ack, body, context }) => { + // Acknowledge the button request + ack(); + + try { + const result = app.client.views.update({ + token: context.botToken, + // Pass the view_id + view_id: body.view.id, + // View payload with updated blocks + view: { + // View identifier + callback_id: 'view_1', + title: { + type: 'plain_text', + text: 'Updated modal' + }, + blocks: [ + { + type: 'section', + text: { + type: 'plain_text', + text: 'You updated the modal!' + } + }, + { + type: 'image', + image_url: 'https://media.giphy.com/media/SVZGEcYt7brkFUyU90/giphy.gif', + alt_text: 'Yay! The modal was updated' + } + ] + } + }); + console.log(result); + } + catch (error) { + console.error(error); + } +}); +``` diff --git a/src/App.ts b/src/App.ts index 4178ed037..1669d6c9e 100644 --- a/src/App.ts +++ b/src/App.ts @@ -12,6 +12,8 @@ import { onlyEvents, matchEventType, matchMessage, + onlyViewSubmits, + matchCallbackId, } from './middleware/builtin'; import { processMiddleware } from './middleware/process'; import { ConversationStore, conversationContext, MemoryStore } from './conversation-store'; @@ -22,6 +24,7 @@ import { SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, + SlackViewMiddlewareArgs, SlackAction, Context, SayFn, @@ -301,6 +304,12 @@ export default class App { ); } + public view(callbackId: string | RegExp, ...listeners: Middleware[]): void { + this.listeners.push( + [onlyViewSubmits, matchCallbackId(callbackId), ...listeners] as Middleware[], + ); + } + public error(errorHandler: ErrorHandler): void { this.errorHandler = errorHandler; } @@ -312,10 +321,8 @@ export default class App { // TODO: when generating errors (such as in the say utility) it may become useful to capture the current context, // or even all of the args, as properties of the error. This would give error handling code some ability to deal // with "finally" type error situations. - // Introspect the body to determine what type of incoming event is being handled, and any channel context const { type, conversationId } = getTypeAndConversation(body); - // If the type could not be determined, warn and exit if (type === undefined) { this.logger.warn('Could not determine the type of an incoming event. No listeners will be called.'); @@ -363,11 +370,13 @@ export default class App { payload: (type === IncomingEventType.Event) ? (bodyArg as SlackEventMiddlewareArgs['body']).event : + (type === IncomingEventType.ViewSubmitAction) ? + (bodyArg as SlackViewMiddlewareArgs['body']).view : (type === IncomingEventType.Action && isBlockActionOrInteractiveMessageBody(bodyArg as SlackActionMiddlewareArgs['body'])) ? (bodyArg as SlackActionMiddlewareArgs['body']).actions[0] : (bodyArg as ( - Exclude | + Exclude | SlackActionMiddlewareArgs> )['body']), }; @@ -389,6 +398,9 @@ export default class App { } else if (type === IncomingEventType.Options) { const optionListenerArgs = listenerArgs as SlackOptionsMiddlewareArgs; optionListenerArgs.options = optionListenerArgs.payload; + } else if (type === IncomingEventType.ViewSubmitAction) { + const viewListenerArgs = listenerArgs as SlackViewMiddlewareArgs; + viewListenerArgs.view = viewListenerArgs.payload; } // Set say() utility @@ -465,11 +477,11 @@ function buildSource( const source: AuthorizeSourceData = { teamId: ((type === IncomingEventType.Event || type === IncomingEventType.Command) ? (body as (SlackEventMiddlewareArgs | SlackCommandMiddlewareArgs)['body']).team_id as string : - (type === IncomingEventType.Action || type === IncomingEventType.Options) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs)['body']).team.id as string : + (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.id as string : assertNever(type)), enterpriseId: ((type === IncomingEventType.Event || type === IncomingEventType.Command) ? (body as (SlackEventMiddlewareArgs | SlackCommandMiddlewareArgs)['body']).enterprise_id as string : - (type === IncomingEventType.Action || type === IncomingEventType.Options) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs)['body']).team.enterprise_id as string : + (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.enterprise_id as string : undefined), userId: ((type === IncomingEventType.Event) ? @@ -478,7 +490,7 @@ function buildSource( ((body as SlackEventMiddlewareArgs['body']).event.channel !== undefined && (body as SlackEventMiddlewareArgs['body']).event.channel.creator !== undefined) ? (body as SlackEventMiddlewareArgs['body']).event.channel.creator as string : ((body as SlackEventMiddlewareArgs['body']).event.subteam !== undefined && (body as SlackEventMiddlewareArgs['body']).event.subteam.created_by !== undefined) ? (body as SlackEventMiddlewareArgs['body']).event.subteam.created_by as string : undefined) : - (type === IncomingEventType.Action || type === IncomingEventType.Options) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs)['body']).user.id as string : + (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).user.id as string : (type === IncomingEventType.Command) ? (body as SlackCommandMiddlewareArgs['body']).user_id as string : undefined), conversationId: channelId, diff --git a/src/helpers.ts b/src/helpers.ts index cf25a3386..f427732f6 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -15,6 +15,7 @@ export enum IncomingEventType { Action, Command, Options, + ViewSubmitAction, } /** @@ -40,16 +41,22 @@ export function getTypeAndConversation(body: any): { type?: IncomingEventType, c }; } if (body.name !== undefined || body.type === 'block_suggestion') { + const optionsBody = (body as SlackOptionsMiddlewareArgs['body']); return { type: IncomingEventType.Options, - conversationId: - (body as SlackOptionsMiddlewareArgs['body']).channel.id, + conversationId: optionsBody.channel ? optionsBody.channel.id : undefined, }; } if (body.actions !== undefined || body.type === 'dialog_submission' || body.type === 'message_action') { + const actionBody = (body as SlackActionMiddlewareArgs['body']); return { type: IncomingEventType.Action, - conversationId: (body as SlackActionMiddlewareArgs['body']).channel.id, + conversationId: actionBody.channel !== undefined ? actionBody.channel.id : undefined, + }; + } + if (body.type === 'view_submission') { + return { + type: IncomingEventType.ViewSubmitAction, }; } return {}; diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index 0a6d90f3c..28458bda2 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -8,12 +8,15 @@ import { SlackEvent, SlackAction, SlashCommand, + ViewSubmitAction, OptionsRequest, InteractiveMessage, DialogSubmitAction, MessageAction, BlockElementAction, ContextMissingPropertyError, + SlackViewMiddlewareArgs, + ViewOutput, } from '../types'; import { ActionConstraints } from '../App'; import { ErrorCode, errorWithCode } from '../errors'; @@ -70,6 +73,47 @@ export const onlyEvents: Middleware next(); }; +/** + * Middleware that filters out any event that isn't a view_submission + */ +export const onlyViewSubmits: Middleware = ({ view, next }) => { + // Filter out anything that isn't a view_submission + if (view === undefined) { + return; + } + + // It matches so we should continue down this middleware listener chain + next(); +}; + +// TODO: is there a way to consolidate the next two methods without too much type refactoring? +export function matchCallbackId( + callbackId: string | RegExp, +): Middleware { + return ({ payload, next, context }) => { + let tempMatches: RegExpMatchArray | null; + + if (!isCallbackIdentifiedBody(payload)) { + return; + } + if (typeof callbackId === 'string') { + if (payload.callback_id !== callbackId) { + return; + } + } else { + tempMatches = payload.callback_id.match(callbackId); + + if (tempMatches !== null) { + context['callbackIdMatches'] = tempMatches; + } else { + return; + } + } + + next(); + }; +} + /** * Middleware that checks for matches given constraints */ @@ -292,11 +336,12 @@ function isBlockPayload( type CallbackIdentifiedBody = | InteractiveMessage | DialogSubmitAction + | ViewOutput | MessageAction | OptionsRequest<'interactive_message' | 'dialog_suggestion'>; function isCallbackIdentifiedBody( - body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'], + body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'] | SlackViewMiddlewareArgs['payload'], ): body is CallbackIdentifiedBody { return (body as CallbackIdentifiedBody).callback_id !== undefined; } diff --git a/src/types/index.ts b/src/types/index.ts index 2c11a8c20..2d05a8ded 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,4 +4,5 @@ export * from './actions'; export * from './command'; export * from './events'; export * from './options'; +export * from './view'; export * from './receiver'; diff --git a/src/types/middleware.ts b/src/types/middleware.ts index 6741e838f..f6f195d00 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -3,10 +3,12 @@ import { SlackEventMiddlewareArgs } from './events'; import { SlackActionMiddlewareArgs } from './actions'; import { SlackCommandMiddlewareArgs } from './command'; import { SlackOptionsMiddlewareArgs } from './options'; +import { SlackViewMiddlewareArgs } from './view'; import { CodedError, ErrorCode } from '../errors'; export type AnyMiddlewareArgs = - SlackEventMiddlewareArgs | SlackActionMiddlewareArgs | SlackCommandMiddlewareArgs | SlackOptionsMiddlewareArgs; + SlackEventMiddlewareArgs | SlackActionMiddlewareArgs | SlackCommandMiddlewareArgs | + SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs; export interface PostProcessFn { (error: Error | undefined, done: (error?: Error) => void): unknown; diff --git a/src/types/options/index.ts b/src/types/options/index.ts index 3c8741319..25e0503b4 100644 --- a/src/types/options/index.ts +++ b/src/types/options/index.ts @@ -27,7 +27,7 @@ export interface OptionsRequest ex enterprise_id?: string; // undocumented enterprise_name?: string; // undocumented }; - channel: { + channel?: { id: string; name: string; }; diff --git a/src/types/view/index.ts b/src/types/view/index.ts new file mode 100644 index 000000000..901b3e55a --- /dev/null +++ b/src/types/view/index.ts @@ -0,0 +1,62 @@ +import { StringIndexed } from '../helpers'; +import { RespondArguments, AckFn } from '../utilities'; + +/** + * Arguments which listeners and middleware receive to process a view submission event from Slack. + */ +export interface SlackViewMiddlewareArgs { + payload: ViewOutput; + view: this['payload']; + body: ViewSubmitAction; + ack: AckFn; +} + +interface PlainTextElementOutput { + type: 'plain_text'; + text: string; + emoji: boolean; +} + +/** + * A Slack view_submission event wrapped in the standard metadata. + * + * This describes the entire JSON-encoded body of a view_submission event. + */ +export interface ViewSubmitAction { + type: 'view_submission'; + team: { + id: string; + domain: string; + enterprise_id?: string; // undocumented + enterprise_name?: string; // undocumented + }; + user: { + id: string; + name: string; + team_id?: string; // undocumented + }; + view: ViewOutput; + api_app_id: string; + token: string; +} + +export interface ViewOutput { + id: string; + callback_id: string; + team_id: string; + app_id: string | null; + bot_id: string; + title: PlainTextElementOutput; + type: string; + blocks: StringIndexed; // TODO: should this just be any? + close: PlainTextElementOutput | null; + submit: PlainTextElementOutput | null; + state: object; // TODO: this should probably be expanded in the future + hash: string; + private_metadata: string; + root_view_id: string | null; + previous_view_id: string | null; + clear_on_close: boolean; + notify_on_close: boolean; + external_id?: string; +}