Slack Next-gen Platform – Advanced Modals

This is translation of  Slack Next-gen Platform – Advanced Modals written by Kaz (SDK Engineering & DevRel at Slack), I do not include any personal opinion here except guessing when meaning of original text is not clear for me)

이 글은 슬랙의 SDK Engineering / DevRel 인 Kaz 의 글을 번역한것입니다. 저의 개인 의견은 들어가지 않으며, 일부 원문의 의미가 애매한 경우에만 부연 설명을 달았습니다.


이번 튜토리얼에서는 Slack 의 차세대 플랫폼의 앱에서 고급 모달 인터렉션을 사용하는 방법에 대해서 배워볼 것입니다.

아마 “The Built-in Forms” 튜토리얼을 읽어보셨을 겁니다. 빌트인 OpenForm 펑션을 사용하면, 사용자의 입력값을 수집하는 간단한 모달 화면을 만드실 수 있죠. 충분히 강력합니다. 그렇지만 제약사항도 존재합니다. 모달 데이터의 서브미션을 위한 커스텀 핸들러 지원, 모달 닫기, 버튼 인터렉션 등이죠.

이번 튜토리얼에서는 Slack 의 모달과 모달의 기초인 Block Kit UI 프레임워크를 사용하여 정교한 유저 인터페이스를 만드는 방법에 대해서 알려드리겠습니다.

Prerequisites

차세대 플랫폼을 접하는 것이 처음이시라면, 이전의 튜토리얼인 “The Simplest Hello World” 부터 읽어주세요. 요약하면 유료 Slack 워크스페이스와 해당 워크스페이스에서 “beta feature” 를 사용할 수 있는 권한이 필요합니다. 가지고 계시다면 Slack CLI 와 워크스페이스를 연결하기만 하면 됩니다.

준비되었다면 앱을 한번 만들어보도록 하죠. 시작해보겠습니다.

Create a Blank Project

slack create 명령어를 사용하여, 새로운 프로젝트를 시작할 수 있습니다. 이 튜토리얼에서는 아무것도 없는 상태에서 앱을 만들어보겠습니다. “Blank Project” 를 선택하여 주십시요.

$ slack create
? Select a template to build from:

  Hello World
  A simple workflow that sends a greeting

  Scaffolded project
  A solid foundational project that uses a Slack datastore

> Blank project
  A, well.. blank project

  To see all available samples, visit github.com/slack-samples.

프로젝트가 생성되면, slack run 명령어가 정상적으로 동작하는지 확인해보세요. 이 명령어는 “dev” 버전의 앱을 워크스페이스에 설치합니다. 이 앱의 봇 유저가 생성되고, 이 봇은 API 호출을 위한 봇 토큰 값을 가지고 있습니다.

$ cd affectionate-panther-654
$ slack run
? Choose a workspace  seratch  T03E94MJU
   App is not installed to this workspace

Updating dev app install for workspace "Acme Corp"

  Outgoing domains
   No allowed outgoing domains are configured
   If your function makes network requests, you will need to allow the outgoing domains
   Learn more about upcoming changes to outgoing domains: https://api.slack.com/future/changelog
  seratch of Acme Corp
Connected, awaiting events

Connected, awaiting events 로그 메세지를 보신다면, 이 앱이 성공적으로 워크스페이스에 연결된 것입니다. “Ctrl +C” 를 눌러 로컬 앱 프로세스를 종료합니다.

Define Workflow and Trigger

간단한 데모 워크플로와 link 트리거를 정의해보겠습니다. workflow_and_trigger.ts 로 저장합니다.

// ----------------
// Workflow Definition
// ----------------
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
export const workflow = DefineWorkflow({
  callback_id: "modal-demo-workflow",
  title: "Modal Demo Workflow",
  input_parameters: {
    properties: { interactivity: { type: Schema.slack.types.interactivity } },
    required: ["interactivity"],
  },
});

// Add your custom function to open and handle a modal
import { def as ModalDemo } from "./function.ts";
workflow.addStep(ModalDemo, {
  interactivity: workflow.inputs.interactivity,
});

// ----------------
// Trigger Definition
// ----------------
import { Trigger } from "deno-slack-api/types.ts";
const trigger: Trigger<typeof workflow.definition> = {
  type: "shortcut", // link trigger
  name: "Modal Demo Trigger",
  workflow: `#/workflows/${workflow.definition.callback_id}`,
  inputs: {
    // Modal interactions require `interactivity` input parameter.
    // As of this writing, only link triggers can provide the value.
    interactivity: { value: "{{data.interactivity}}" },
  },
};
export default trigger;

아직 function.ts 를 안만들었기 때문에 컴파일을 당연히 실패할 것입니다. function.ts 를 만들어 줍시다.

import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";

export const def = DefineFunction({
  callback_id: "modal-example",
  title: "Modal interaction example",
  source_file: "function.ts",
  input_parameters: {
    properties: { interactivity: { type: Schema.slack.types.interactivity } },
    required: ["interactivity"],
  },
  output_parameters: { properties: {}, required: [] },
});

export default SlackFunction(
  def,
  // ---------------------------
  // The first handler function that opens a modal.
  // This function can be called when the workflow executes the function step.
  // ---------------------------
  async ({ inputs, client }) => {
    // Open a new modal with the end-user who interacted with the link trigger
    const response = await client.views.open({
      interactivity_pointer: inputs.interactivity.interactivity_pointer,
      view: {
        "type": "modal",
        // Note that this ID can be used for dispatching view submissions and view closed events.
        "callback_id": "first-page",
        // This option is required to be notified when this modal is closed by the user
        "notify_on_close": true,
        "title": { "type": "plain_text", "text": "My App" },
        "submit": { "type": "plain_text", "text": "Next" },
        "close": { "type": "plain_text", "text": "Close" },
        "blocks": [
          {
            "type": "input",
            "block_id": "first_text",
            "element": { "type": "plain_text_input", "action_id": "action" },
            "label": { "type": "plain_text", "text": "First" },
          },
        ],
      },
    });
    if (response.error) {
      const error =
        `Failed to open a modal in the demo workflow. Contact the app maintainers with the following information - (error: ${response.error})`;
      return { error };
    }
    return {
      // To continue with this interaction, return false for the completion
      completed: false,
    };
  },
)
  // ---------------------------
  // The handler that can be called when the above modal data is submitted.
  // It saves the inputs from the first page as private_metadata,
  // and then displays the second-page modal view.
  // ---------------------------
  .addViewSubmissionHandler(["first-page"], ({ view }) => {
    // Extract the input values from the view data
    const firstText = view.state.values.first_text.action.value;
    // Input validations
    if (firstText.length < 5) {
      return {
        response_action: "errors",
        // The key must be a valid block_id in the blocks on a modal
        errors: { first_text: "Must be 5 characters or longer" },
      };
    }
    // Successful. Update the modal with the second page presentation
    return {
      response_action: "update",
      view: {
        "type": "modal",
        "callback_id": "second-page",
        // This option is required to be notified when this modal is closed by the user
        "notify_on_close": true,
        "title": { "type": "plain_text", "text": "My App" },
        "submit": { "type": "plain_text", "text": "Next" },
        "close": { "type": "plain_text", "text": "Close" },
        // Hidden string data, which is not visible to end-users
        // You can use this property to transfer the state of interaction
        // to the following event handlers.
        // (Up to 3,000 characters allowed)
        "private_metadata": JSON.stringify({ firstText }),
        "blocks": [
          // Display the inputs from "first-page" modal view
          {
            "type": "section",
            "text": { "type": "mrkdwn", "text": `First: ${firstText}` },
          },
          // New input block to receive text
          {
            "type": "input",
            "block_id": "second_text",
            "element": { "type": "plain_text_input", "action_id": "action" },
            "label": { "type": "plain_text", "text": "Second" },
          },
        ],
      },
    };
  })
  // ---------------------------
  // The handler that can be called when the second modal data is submitted.
  // It displays the completion page view with the inputs from
  // the first and second pages.
  // ---------------------------
  .addViewSubmissionHandler(["second-page"], ({ view }) => {
    // Extract the first-page inputs from private_metadata
    const { firstText } = JSON.parse(view.private_metadata!);
    // Extract the second-page inputs from the view data
    const secondText = view.state.values.second_text.action.value;
    // Displays the third page, which tells the completion of the interaction
    return {
      response_action: "update",
      view: {
        "type": "modal",
        "callback_id": "completion",
        // This option is required to be notified when this modal is closed by the user
        "notify_on_close": true,
        "title": { "type": "plain_text", "text": "My App" },
        // This modal no longer accepts further inputs.
        // So, the "Submit" button is intentionally removed from the view.
        "close": { "type": "plain_text", "text": "Close" },
        // Display the two inputs
        "blocks": [
          {
            "type": "section",
            "text": { "type": "mrkdwn", "text": `First: ${firstText}` },
          },
          {
            "type": "section",
            "text": { "type": "mrkdwn", "text": `Second: ${secondText}` },
          },
        ],
      },
    };
  })
  // ---------------------------
  // The handler that can be called when the second modal data is closed.
  // If your app runs some resource-intensive operations on the backend side,
  // you can cancel the ongoing process and/or tell the end-user
  // what to do next in DM and so on.
  // ---------------------------
  .addViewClosedHandler(
    ["first-page", "second-page", "completion"],
    ({ view }) => {
      console.log(`view_closed handler called: ${JSON.stringify(view)}`);
      return { completed: true };
    },
  );

상세한 내용은 추후에 설명드릴 것입니다만, 여기서 보셔야 할 부분은 다음과 같습니다.

  • 첫 핸들러가 앤드유저를 위한 모달을 오픈합니다.
  • 모달 데이터 서브미션 이벤트를 보낼때에는 addViewSubmissionHandler() 로 핸들러를 등록하고+ a 모달의 callback_id 를 사용합니다.
  • 모달을 닫는 이벤트를 보낼때에는 addViewClosedHandler() 로 핸들러를 등록하고 + 모달의 callback_id 를 사용합니다.

Create a Link Trigger

인터렉션을 위하여 link 트리거를 만들어 보겠습니다.

$ slack triggers create --trigger-def workflow_and_trigger.ts
? Choose an app  seratch (dev)  T03E94MJU
   sharp-chipmunk-480 (dev) A04G9S43G2K


Trigger created
   Trigger ID:   Ft04GZK1EE3E
   Trigger Type: shortcut
   Trigger Name: Modal Demo Trigger
   URL: https://slack.com/shortcuts/***/***

생성된 link 트리거 URL 을 채널에 공유하고 눌러보세요. 예상하신 것처럼 모달과 인터렉션이 되는것을 보실 수 있습니다. 이 모달은 여러분이 입력한 값을 두번째 페이지로 전달하며, 마지막 페이지는 2개의 입력값을 보여줍니다.

첫번째 데이터 서브미션을 다룰 때, 핸들러는 입력값이 유효한지를 체크 합니다. (여기서는 길이), 만약 입력값이 유효하면, response_action: “update” 를 새로운 모달 화면에 리턴 합니다. 또한, 이 코드는 입력값을 JSON 문자 데이터 형태로 전달합니다. 그리고 이 JSON 문자 데이터는 모달 화면에서 private_metadata 로 내장시킬 수 있습니다.

.addViewSubmissionHandler(["first-page"], ({ view }) => {
  // Extract the input values from the view data
  const firstText = view.state.values.first_text.action.value;
  // Input validations
  if (firstText.length < 5) {
    return {
      response_action: "errors",
      // The key must be a valid block_id in the blocks on a modal
      errors: { first_text: "Must be 5 characters or longer" },
    };
  }
  // Successful. Update the modal with the second-page presentation
  return {
    response_action: "update",
    view: {
      "type": "modal",
      "callback_id": "second-page",
      "private_metadata": JSON.stringify({ firstText }),
      ...
    },
  };
})

두번째 페이지를 다룰 때에, 이 핸들러는 view_private_metadataview.state.values 로부터 값을 추출합니다. 그리고 업데이트 된 화면은 단일 모달 화면에서 두개의 값을 보여줍니다.

.addViewSubmissionHandler(["second-page"], ({ view }) => {
  // Extract the first-page inputs from private_metadata
  const { firstText } = JSON.parse(view.private_metadata!);
  // Extract the second-page inputs from the view data
  const secondText = view.state.values.second_text.action.value;
  // Displays the third page, which tells the completion of the interaction
  return { response_action: "update", view: { ... } };
  };
})

마지막으로, addViewClosedHandler() 메소드 콜을 통해서 단일 핸들러를 등록하여 앱에서 모든 모달을 닫는 이벤트를 다를 수 있습니다.

.addViewClosedHandler(
  ["first-page", "second-page", "completion"],
  ({ view }) => {
    console.log(`view_closed handler called: ${JSON.stringify(view)}`);
    return { completed: true };
  },
);

A Few Things To Know

addViewSubmissionHandler() 를 통해서 등록된 핸들러들은 반드시 3초 이내에 완료되어야 합니다. (추후에는 변경될 수도 있습니다), 만약 핸들러가 시간을 좀 잡아먹는 작업들을 수행한다면, 2가지의 옵션이 있습니다.

  • 모달에 “Processing” 화면을 먼저 업데이트 하고, 봇 토큰을 백엔드 서비스로 전달합니다. 그리고 백엔드 사이드에서 프로세스가 완료되었을 때 views.update API 를 호출합니다.
  • 일단 모달에서 인터렉션을 종료하고, DM 등을 통하여 유저와 커뮤니케이션을 진행합니다.

또한, 만약 Slack 모달을 오랫동안 써오신 분이라면, trigger_id 대신에 interactivity_pointer 를 넘겨주어야 하는지에 의문을 가지실 수 있는데, 사실 두가지는 똑같은 방식으로 동작합니다. 차이점은 값을 받아오는 방식입니다. 현 플랫폼에서는 인터렉트 이벤트 페이로드에서 trigger_id 를 제공하지만, interactivity_pointerinputs.interactivity 에서만 받아올 수 있습니다.

Wrapping Up

이번 튜토리얼을 통해서 다음과 같은 내용을 배우셨습니다.

  • 커스텀 펑션에서 모달을 시작하는 방법
  • 모달에서 데이터 서브미션을 다루는 방법

이 프로젝트는 다음 주소에서도 확인하실 수 있습니다. https://github.com/seratch/slack-next-generation-platform-tutorials/tree/main/10_Advanced_Modals

이번 튜토리얼도 즐거우셨으면 좋겠네요. 마찬가지로 피드백이나 코멘트가 있으시다면 트위터 (@seratch) 로 연락주시거나, 여기 남겨주세요.

Happy hacking with Slack’s next-generation platform 🚀

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다