slackapi / deno-slack-sdk

SDK for building Run on Slack apps using Deno
https://api.slack.com/automation
MIT License
162 stars 27 forks source link

Modal view failing to update if view is large #324

Open robinstauf opened 4 months ago

robinstauf commented 4 months ago

Hello!

My Slack app uses a multi-page interactive modal. This is the third time I've run into a situation where I'm unable to update the modal view because of the size of my blocks. I'm within the limits of each individual block kit element, but I believe the size of the view as a whole is what's causing the view update to time out. I've looked through the Slack api documentation and scanned through the open and closed issues here, but haven't found anything talking about this issue.

In the example I ran into today, the view I'm trying to update to consists of a static multi-select with 64 options, a file input element, a url input element, and a multiline plain text element (along with a few short mrkdwn sections and dividers). This page loads correctly when I've tested it with ~20 options in the multi-select list, but with 64 it fails to load and shows the generic "We had some trouble connecting" message. It doesn't show any warnings or errors in the terminal. The block kit documentation states that a static multi-select list can contain up to 100 options, with each option text being less than 75 characters (I double checked and I don't have any options with more than ~25 characters).

Another example of this happening was when I tried to update to a view with 8 static select element. The blocks for that view only contained these static select elements, and each static select only had 3-4 options. When I commented out any 2 of the 8 static selects and displayed only 6, the view would successfully update.

The third time I remember this happening, the view I was attempting to update to didn't have any input fields, just a lot of context and divider blocks (I don't remember exactly how many, probably around 20 context blocks and 6 dividers). I hit the same issue where the view wouldn't update unless I commented out a random handful of blocks.

I haven't been able to find any documented limits for "modal view size". All of these views display successfully in the Block Kit Builder, so I'm confused why the same view is unable to load when I try to display it in my Slack app. I tried asking Slack support about this issue and they directed me here.

Let me know if I can provide any additional information, thank you!

zimeg commented 4 months ago

Hey @robinstauf 👋 Super interesting to hear about such large modals, thanks for writing in!

Are errors being returned from calls to views.update? My immediate guess is these views are exceeding 250kb and a view_too_large code is returned, but if the API call seems to work without a view being updated this might be a bug :thinking:

If it's possible to split views into multiple smaller modals, that might be an alright solution for avoiding this strangeness and the approach I would probably take. Also want to share a few other suggestions for designing modals might be useful for this process of collecting modal inputs - it's some fun reading!

zimeg commented 4 months ago

Quick follow up: If this does seem to be an API-specific problem I'll recommend emailing feedback@slack.com with a few details around the time of these API calls, related team IDs, and similar information so that our teams can take a closer look at the exact cause of these failing updates on the server side of things! But please let me know if this seems like an expected view_too_large error too. We want to make sure the SDKs are making all the right API calls, even if these might fail sometimes :pray:

robinstauf commented 4 months ago

Thanks for the quick response @zimeg!

I've been using return: { response action: update , view: next_view } in submission handlers to update my view. This hasn't thrown any errors, but there's also not an easy way to explicitly check for errors with this method (that I've found?).

I just tried using views.update instead, and that seems to work! It still shows the "We had some trouble connecting" message, but it does successfully update to the next page with the large select list. I'm trying to figure out if that "trouble connecting" warning message is related to this issue or if that's being caused by something else.

Should I still send an email about the issue updating with response_action: update?

filmaj commented 4 months ago

@robinstauf if you use the inline return { response_action } approach, then you have to make sure that that return happens within 3 seconds of the event that you are handling being sent from Slack. One cause for the "Trouble connecting" message may be due to that 3 second timeout expiring. If that is not the case, then this may be an issue on Slack's end, though we'd have to investigate the specific app/team situation, and at this point we'd have to involve Customer Support as they are the ones who are equipped to access customer data such as workspace and app specifics.

Can you provide more context as to the circumstances under which you are trying to update the view? It sounds like it is in response to some specific event, since response_action is usually related to view or block action events.

Some other random musings here:

robinstauf commented 4 months ago

Hi @filmaj!

Right now my modal has about 7 screens, each with 3-5 inputs. Between each screen, I'm saving information submitted on the previous screen to a datastore and passing that datastore id through the private_metadata parameter. I use view submission handlers and { response_action: update } to update to each next screen.

It doesn't seem like a 3 second timeout, but it could be? When I use view.update to load the view it takes less than a second to load the screen, but still shows that the "We had some trouble connecting" error. I see that Slack's documentation states,

If you want to modify a modal in response to a block_actions interaction, your app must send the acknowledgment response. Then the app can use the view.* API endpoints explained below to make desired modifications.

If you want to modify a modal in response to a view_submission interaction, your app can include a valid response_action with the acknowledgment response. We'll explain how to do that below.

so I'm wondering if that error is because I'm using view.update inside a view submission handler? I had to return completed: false at the end of the view submission handler otherwise the modal would close immediately. When I hit "try again" in that "trouble connecting" banner, it tries to submit the updated page.

I tried removing the additional logic happening in the view submission handler (the async call to save to the datastore and the async call to grab the members of a Slack channel to populate the select list) to see if that would help the page not time out, but even when the submission handler did nothing but return the new view, it fails.

have you tried using view.push API instead of view.update, to take advantage of the Modal Stack? Not always applicable to every use case but a stack concept for views can be quite handy in certain situations.

I'm not sure if view.push would work in my case since I have so many different screens in this modal. I tried giving it a shot for this view specifically using const push_resp = await client.views.push({ interactivity_pointer: body.interactivity.interactivity_pointer, view: next_view});, like the example shown in the documentation, but got the error message "invalid interactivity pointer". I also tried const push_resp = await client.views.push({ trigger_id: body.trigger_id, view: next_view});, which gave the error message "invalid arguments".

(Side note: I just noticed that the example I'm linking above shows view.update being used in a view submission handler, so maybe that is allowed behavior)

what about breaking up the experience you are trying to craft into separate, individual views? This is less of a solution and more of a workaround, but if the issue is the view size, the intention behind this suggestion is to break the views down into smaller, bite-sized pieces, and serve less e.g. form inputs, etc. to the user at any one time, and spread the total amount of information being collected from the user across more views.

Yeah this is what I ended up doing the first couple of times I ran into this issue. Since I already have 7 screens in this flow and this page only has 4 total inputs I would prefer to keep the design as is, but if it looks like that won't be possible I can definitely split that page up! I wanted to reach out to see if this issue is an actual limitation or if it's a bug on Slack's end.

Thank you for looking into this! <3

filmaj commented 4 months ago

so I'm wondering if that error is because I'm using view.update inside a view submission handler? I had to return completed: false at the end of the view submission handler otherwise the modal would close immediately. When I hit "try again" in that "trouble connecting" banner, it tries to submit the updated page.

Can you share your code? It would be nice to see the relevant parts of the entire custom function, including the top-level function implementation as well as any relevant handlers you have scoped to that function (sounds like there are, at least, view submission handlers present).

I have an example repo of an app with a custom function that implements an approval flow using modals and view/block action handlers: https://github.com/filmaj/interactive-approval/. Perhaps it might be helpful as a guide. Key bits:

Yeah this is what I ended up doing the first couple of times I ran into this issue. Since I already have 7 screens in this flow and this page only has 4 total inputs I would prefer to keep the design as is, but if it looks like that won't be possible I can definitely split that page up!

Nah, given you only have a few inputs on each view, my expectation is that this should work fine! The little example interactivity app I have above sounds similar to what you have, so I'd like to get to the bottom of what is causing the issue in your case. If you can share any code to help me reproduce this issue, I'm sure we will get to the bottom of it.

robinstauf commented 4 months ago

@filmaj Here's some code, lmk if you need more info for any piece of this:

The "top level" function grabs a few things to generate the first view, then opens the modal using

const response = await client.views.open({
      interactivity_pointer: inputs.interactivity.interactivity_pointer,
      view: view,
    });

I check to make sure it was successful, then end that function by returning completed: false

That function has 7 view submission handlers, all following the same general structure. Each view has it's own submission handler set up (by callback_id). The view submission handler will perform validation checks, then if those checks are successful it will update the datastore with the submitted values, generate the next view, then return response_action: update to display that view.

Here's code for the view submission handler that generates and attempts to update to the "large" view:

.addViewSubmissionHandler(
    "solutions_2",
    async ({ client, view, token, body }) => {
      const request_uuid = view.private_metadata || "";
      const submitted_values = view.state.values;

      const workarounds = submitted_values.workarounds.action.value;
      const evu_link = submitted_values.evu_link.action.value;
      const miro_link = submitted_values.miro_link.action.value;
      const coda_link = submitted_values.coda_link.action.value;
      const zoom_link = submitted_values.zoom_link.action.value;

      // check if user entered at least one url
      if (!evu_link && !miro_link && !coda_link && !zoom_link) {
        return {
          "response_action": "errors",
          "errors": {
            "evu_link":
              "Please submit at least one link showing the current user process",
            "miro_link":
              "Please submit at least one link showing the current user process",
            "coda_link":
              "Please submit at least one link showing the current user process",
            "zoom_link":
              "Please submit at least one link showing the current user process",
          },
        };
      }

      // check for url errors
      const errors: Record<string, string> = {};
      if (evu_link && !is_valid_url(evu_link)) {
        errors.evu_link =
          "Invalid url. Please make sure your url starts with https:// or http://";
      }
      if (miro_link && !is_valid_url(miro_link)) {
        errors.miro_link =
          "Invalid url. Please make sure your url starts with https:// or http://";
      }
      if (coda_link && !is_valid_url(coda_link)) {
        errors.coda_link =
          "Invalid url. Please make sure your url starts with https:// or http://";
      }
      if (zoom_link && !is_valid_url(zoom_link)) {
        errors.zoom_link =
          "Invalid url. Please make sure your url starts with https:// or http://";
      }

      // // display any url errors
      if (Object.keys(errors).length > 0) {
        return {
          "response_action": "errors",
          "errors": errors,
        };
      }

      // update datastore
      const gpr_fields: GoodProblemRequest = {
        id: request_uuid,
        workarounds: workarounds,
        evu_link: evu_link,
        miro_link: miro_link,
        coda_link: coda_link,
        zoom_link: zoom_link,
      };
      await datastore_update_gpr(client, gpr_fields); // calls client.apps.datastore.update and checks the return value

      const next_view = solutions_3_view(request_uuid); // code for this shown below

       return {
         response_action: "update",
         view: next_view,
      };

      //  this commented out code is what I tried after your first response (instead of returning the response action)

      // const push_resp = await client.views.update({
      //   view_id: body.view.id,
      //   // trigger_id: body.trigger_id,
      //   // external_id: "solutions_2",
      //   // interactivity_pointer: body.interactivity.interactivity_pointer,
      //   view: next_view,
      // });

      // if (push_resp.error) {
      //   const error = `Failed to open modal - (error: ${push_resp.error})`;
      //   console.log(error);
      //   // return { error };
      // }

      // return { completed: false };
    }

Here is the code for solutions_3_view():

export const solutions_3_view = (
  request_uuid: string,
  init_values?: GoodProblemRequest,
): ModalView => {

  const lead_options: PlainTextOption[] = [
    {
      text: { type: "plain_text", text: "User Name", emoji: true },
      value: "User Name",
    },
    // plus an additional 63 PlainTextOption objects
  ]

  const leads_circled_with: InputBlock = {
    block_id: "leads_circled_with",
    type: "input",
    optional: true,
    label: {
      type: "plain_text",
      text: "Leaders circled with: who have you talked this through with?",
    },
    element: {
      type: "multi_static_select",
      options: lead_options,
      action_id: "action",
    },
  };
  const files_header: HeaderBlock = {
    type: "header",
    text: {
      type: "plain_text",
      text: "Document Uploads and Links to messy notes",
    },
  };
  const files_descr: SectionBlock = {
    "type": "section",
    "text": {
      "type": "mrkdwn",
      "text": "Provide any additional links or uploads that might be helpful",
    },
  };
  const submitted_files: InputBlock = {
    block_id: "submitted_files",
    type: "input",
    optional: true,
    label: {
      type: "plain_text",
      text:
        "Upload any pages docs / numbers docs / screenshots you have on this",
    },
    element: {
      type: "file_input",
      action_id: "action",
      max_files: 1,
    },
  };
  const notes_coda_link: InputBlock = {
    block_id: "notes_coda_link",
    type: "input",
    optional: true,
    element: {
      type: "url_text_input",
      action_id: "action",
      initial_value: init_values?.notes_coda_link || undefined,
    },
    label: {
      type: "plain_text",
      text: "Coda link to page of thoughts on this request",
    },
  };
  const notes: InputBlock = {
    block_id: "notes",
    type: "input",
    optional: true,
    label: {
      type: "plain_text",
      text:
        "Paste text from Apple Notes you've kept on this problem or let us know any other thoughts you have",
    },
    element: {
      type: "plain_text_input",
      action_id: "action",
      multiline: true,
      initial_value: init_values?.notes || undefined,
    },
  };

  const view: ModalView = {
    type: "modal",
    callback_id: "solutions_3",
    notify_on_close: true,
    title: {
      type: "plain_text",
      text: "Solutions",
    },
    submit: {
      type: "plain_text",
      text: "Next",
    },
    close: {
      type: "plain_text",
      text: "Cancel",
    },
    private_metadata: request_uuid,
    blocks: [
      leads_circled_with,
      div_block,
      files_header,
      files_descr,
      submitted_files,
      notes_coda_link,
      notes,
    ],
  };

  return view;
};

Even when I remove all additional logic in the view submission handler and only call,

const request_uuid = view.private_metadata || "";
const next_view = solutions_3_view(request_uuid);
return {
     response_action: "update",
     view: next_view,
};

the view update still fails. So it doesn't seem like the datastore update or validation checking is contributing to this issue. Let me know if you need more information here, or if you'd want to jump on a call to talk through this together! Thanks!

filmaj commented 4 months ago

Great, thanks for sharing! The fact you pared the example down to just a response_action: update call with a simple looking Block Kit payload is going to be very helpful. I have a feeling this is some issue on the backend w/ your particular block payload. I will investigate today and comment with what I find.

And just to double-check one thing: the workflow that kicks off this custom function, it is triggered by a link trigger, yes? I assume that is where the interactivity_pointer your custom function uses to open a view comes from, yes?

filmaj commented 4 months ago

Update here: using your particular response_action: update payload, I'm able to reproduce the "trouble connecting" error.

filmaj commented 4 months ago

I experimented a bit more and I think this has something to do with the number of PlainTextOptions present in the update payload. If I include 7 or less plain text options, the view updates fine. If I include 8 or more options, I get the 'trouble connecting' error in the view dialog.

I am investigating some more.

In the mean time, can you tell me if you are seeing this when you slack deploy your app, or if you slack run your app locally, or both?

filmaj commented 4 months ago

I also tried experimenting with calling the views.update API instead of using the inline response_action: update response. Indeed, to your earlier point, I had to make sure the view submission handler returns { completed: false } in order for the modal not to be closed - which surprised me. Another issue to file away (but secondary to the main one we're dealing with here).

The interesting thing about using the views.update API instead of response_action: update is that I get an ok: true response from the API, and the view is updated correctly, BUT the view still renders a "we had some trouble connecting" error at the top. 🤨 I was able to increase the number of plain text options up to various amounts using this approach, too. According to the Errors list on the views.update API, a view_too_large error will be returned if the full view payload is bigger than 250kb. Even with 100 PlainTextOptions (the maximum allowed), the view payload only gets to about ~12kb, so that should be well within the range.

I'm going to engage a backend team at Slack to help investigate this one - your patience is appreciated while we dig in 🙇

robinstauf commented 4 months ago

@filmaj Thanks for the detailed responses! To answer your questions above:

And just to double-check one thing: the workflow that kicks off this custom function, it is triggered by a link trigger, yes? I assume that is where the interactivity_pointer your custom function uses to open a view comes from, yes?

Yes - this workflow is triggered by a link trigger, and this custom function is the first step of the workflow. In the trigger definition I'm grabbing TriggerContextData.Shortcut.interactivity and passing that in as an input to the workflow and function.

Can you tell me if you are seeing this when you slack deploy your app, or if you slack run your app locally, or both?

I haven't tried deploying this app yet since I'm working out of my company's production Slack environment. I've only tested this locally using slack run.

If I include 7 or less plain text options, the view updates fine. If I include 8 or more options, I get the 'trouble connecting' error in the view dialog.

This is what I noticed too, although I've seen it working with up to 15ish PlainTextOptions

Even with 100 PlainTextOptions (the maximum allowed), the view payload only gets to about ~12kb, so that should be well within the range.

Thank you for checking this, I wasn't sure how to check the size of my payload and I was worried that was the issue that I was running into. Good to know I'm within the limits!

I'm glad that you were able to reproduce the issues with both response_action: update and client.views.update on your end! Thanks for bringing in the backend team to look at this issue. Let me know if you need any more info from me!

filmaj commented 4 months ago

An update here: this seems to only be an error when using slack run. A slack deployed app has no issue. I have some backend job/dispatcher logs handy to help further chase this down, but this seems to be related to the WebSocket-based message sending of slack run and the WS-based dispatching / worker queue of view events.

I will be engaging the relevant backend team at Slack to help me diagnose this problem.