Elsa Workflow - How to implement cascade dropdown

1.2k views Asked by At

I have two dropdowns in an activity. One of them it's populated dynamically from OptionsProvider attribute.

I would to like to populate a second dropdown from the first one.

How to get that?

Regards, Gustavo

1

There are 1 answers

1
Sipke Schoorstra On BEST ANSWER

To achieve this, you need to write a designer plugin that basically does the following:

  1. When the activity editor appears, get the select value of the first dropdown.
  2. Based on the value of the first dropdown, get a list of items for the second dropdown. You did not specify from where you want to get these items, so I will assume you are getting these from an API endpoint.
  3. Handle the "change" event of the first dropdown (and repeat step 2).
  4. When the activity editor appears, you will want to initialize the second dropdown with the value as last selected. This information is stored in the activity.

As an example, I updated the "vehicle activity" sample found https://github.com/elsa-workflows/elsa-core/blob/master/src/samples/server/Elsa.Samples.Server.Host/Activities/VehicleActivity.cs.

The sample activity demonstrates how one might implement cascading dropdown behavior by letting the user first select the car Brand, then pick a Model from the second dropdown.

Here's what that looks like in action:

Elsa 2 - Cascading Dropdowns Demo

The activity's code is straightforward enough, so I will only show its two properties that are of interest:

[ActivityInput(
    UIHint = ActivityInputUIHints.Dropdown,
    OptionsProvider = typeof(VehicleActivity),
    DefaultSyntax = SyntaxNames.Literal,
    SupportedSyntaxes = new[] { SyntaxNames.Literal, SyntaxNames.JavaScript, SyntaxNames.Liquid }
)]
public string? Brand { get; set; }

[ActivityInput(
    UIHint = ActivityInputUIHints.Dropdown,
    DefaultSyntax = SyntaxNames.Literal,
    SupportedSyntaxes = new[] { SyntaxNames.Literal, SyntaxNames.JavaScript, SyntaxNames.Liquid }
)]
public string? Model { get; set; }

The most important aspect is the UIHint that is set to ActivityInputUIHints.Dropdown - which tells the designer to render the input editor a dropdown.

The next step is to create & install a plugin for the designer. Ideally, you already have a StencilJS project in which you encapsulate the designer, but we can do it with just JavaScript directly in your HTML page too. The following snippet shows a scaffold of a plugin and how to install it into the designer:

// A plugin is just a function that receives access to `elsaStudio` services.
function VehicleActivityPlugin(elsaStudio) { }

// To install the plugin, get a reference to the <elsa-studio-root> element:
const elsaStudioRoot = document.querySelector('elsa-studio-root');

// Then install the plugin during the 'initializing' event:
elsaStudioRoot.addEventListener('initializing', e => {
   const elsa = e.detail;
   elsa.pluginManager.registerPlugin(VehicleActivityPlugin);
}

Now it's up to your plugin to find the appropriate elements when the activity editor is displayed for the custom activity.

For example:

function VehicleActivityPlugin(elsaStudio) {
  // Get access to the eventBus to observe events and httpClientFactory to make API calls.
  const {eventBus, httpClientFactory} = elsaStudio;

  // When the activity editor is opened, setup an event handler on the Brands dropdown list.
  eventBus.on('activity-editor-appearing', async e => {
    // We are only interested in our custom activity being editor.
    if (e.activityDescriptor.type !== 'VehicleActivity')
      return;

    // Listen for change events on the Brand dropdown list.
    const brandsSelectList = await awaitElement('#Brand'); // awaitElement is a custom function that returns a promise that resolves after the element exists.

    const currentBrand = brandsSelectList.value;

    // Get the current value of the Model property.
    const currentModel = e.activityModel.properties.find(p => p.name === 'Model').expressions['Literal'];

    // Setup a change handler for when the user changes the selected brand.
    brandsSelectList.addEventListener('change', async e => {
      await updateModels(e.currentTarget.value);
    });

    // Update the second dropdown with available options based on the current brand (if any).
    // Also provide the currently selected model, if any.
    await updateModels(currentBrand, currentModel);
  });

  // When the activity editor is closing, dispose event handlers.
  eventBus.on('activity-editor-disappearing', e => {
    if (e.activityDescriptor.type !== 'VehicleActivity')
      return;

    document.querySelector('#Brand').removeEventListener('change', updateModels);
  });
}

I left out two functions to try and show the structure of the plugin, but here are the missing awaitElement and updateModels functions:

// Awaits the existence of an element.
// Taken from this [SO answer][3].
const awaitElement = async selector => {
  while ( document.querySelector(selector) === null) {
    await new Promise( resolve =>  requestAnimationFrame(resolve) )
  }
  return document.querySelector(selector);
};

The updateModels function should live inside the plugin, because it requires access to the httpClientFactory in this example:

// A function that requests a list of models from the server based on the selected car brand.
const updateModels = async (brand, currentModel) => {
  let models = [];

  // Only attempt to fetch car models if a brand was given.
  if (!!brand) {
    const httpClient = await httpClientFactory();
    const response = await httpClient.get(`api/samples/brands/${brand}/models`);
    models = response.data;
  }

  // Get a reference to the models select list.
  const modelsSelectList = await awaitElement('#Model');
  modelsSelectList.innerHTML = "";

  // Build up the models dropdown list.
  for (const model of models) {
    const selected = model === currentModel;
    const option = new Option(model, model, selected, selected);
    modelsSelectList.options.add(option);
  }
}

The complete plugin code should look like this:

// A sample plugin for the VehicleActivity sample activity.
  function VehicleActivityPlugin(elsaStudio) {
    const {eventBus, httpClientFactory} = elsaStudio;

    // A function that requests a list of models from the server based on the selected car brand.
    const updateModels = async (brand, currentModel) => {
      let models = [];

      // Only attempt to fetch car models if a brand was given.
      if (!!brand) {
        const httpClient = await httpClientFactory();
        const response = await httpClient.get(`api/samples/brands/${brand}/models`);
        models = response.data;
      }

      // Get a reference to the models select list.
      const modelsSelectList = await awaitElement('#Model');
      modelsSelectList.innerHTML = "";

      // Build up the models dropdown list.
      for (const model of models) {
        const selected = model === currentModel;
        const option = new Option(model, model, selected, selected);
        modelsSelectList.options.add(option);
      }
    }

    // When the activity editor is opened, setup an event handler on the Brands dropdown list.
    eventBus.on('activity-editor-appearing', async e => {
      // We are only interested in our custom activity being editor.
      if (e.activityDescriptor.type !== 'VehicleActivity')
        return;

      // Listen for change events on the Brand dropdown list.
      const brandsSelectList = await awaitElement('#Brand');
      const currentBrand = brandsSelectList.value;
      const currentModel = e.activityModel.properties.find(p => p.name === 'Model').expressions['Literal'];

      brandsSelectList.addEventListener('change', async e => {
        await updateModels(e.currentTarget.value);
      });

      await updateModels(currentBrand, currentModel);
    });

    // When the activity editor is closing, dispose event handlers.
    eventBus.on('activity-editor-disappearing', e => {
      if (e.activityDescriptor.type !== 'VehicleActivity')
        return;

      document.querySelector('#Brand').removeEventListener('change', updateModels);
    });
  }