ember.jshandlebars.jsember-dataweb-development-serverhbs

How to implement dynamic filtering in Ember.js dropdowns?


I'm working on an Ember.js application and I need to implement two dropdowns where the options in the second dropdown are filtered based on the selected value in the first dropdown. I have the following requirements:

The second dropdown should fetch its options from an API endpoint. Whenever a value is selected in the first dropdown, the options in the second dropdown should be filtered accordingly. The filtering should happen dynamically without a page refresh. I have tried implementing this functionality, but I'm facing some issues. The second dropdown does not update its options when a new value is selected in the first dropdown. How can I achieve this dynamic filtering behavior?

Here's a simplified version of the code I currently have: I have a parent and then 2 dropdown components inside it. I send the slected value of first dropdown from parent to the second dropdown. But the issue is that the new data is not filtered based on the value of first dropdown (namely: this.SelectedBU (injected from parent)). The component is very complex, thus I am only posting the index.js and index.hbs for the second dropsown.

second_dropdown.hbs

{{! @glint-nocheck - not typesafe yet }}
{{! https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ }}
<h3>{{@this.selectedBU}}</h3>
<div data-test-product-select>
  {{#if this.teams}}
    {{#if @formatIsBadge}}
      <Inputs::BadgeDropdownList
        @items={{this.teams}}
        @listIsOrdered={{true}}
        @onItemClick={{this.onChange}}
        @selected={{@selected}}
        @placement={{@placement}}
        @isSaving={{@isSaving}}
        @renderOut={{@renderOut}}
        @icon={{this.icon}}
        class="w-80 product-select-dropdown-list"
        ...attributes
      >
        <:item as |dd|>
          <dd.Action data-test-product-select-badge-dropdown-item>
            <Inputs::TeamSelect::Item
              @product={{dd.value}}
              @selected={{dd.selected}}
            />
          </dd.Action>
        </:item>
      </Inputs::BadgeDropdownList>
    {{else}}
      <X::DropdownList
        @items={{this.teams}}
        @listIsOrdered={{true}}
        @onItemClick={{this.onChange}}
        @selected={{@selected}}
        @placement={{@placement}}
        @isSaving={{@isSaving}}
        @renderOut={{@renderOut}}
        class="w-[300px] product-select-dropdown-list"
        ...attributes
      >
        <:anchor as |dd|>
          <dd.ToggleAction
            class="x-dropdown-list-toggle-select product-select-default-toggle hds-button hds-button--color-secondary hds-button--size-medium"
          >
            <FlightIcon @name={{or (get-product-id @selected) "folder"}} />

            <span
              class="product-select-selected-value
                {{unless @selected 'text-color-foreground-faint'}}"
            >
              {{or @selected "Select your team/pod"}}
            </span>

            {{#if this.selectedProductAbbreviation}}
              <span class="product-select-toggle-abbreviation">
                {{this.selectedProductAbbreviation}}
              </span>
            {{/if}}

            <FlightIcon @name="caret" class="product-select-toggle-caret" />
          </dd.ToggleAction>
        </:anchor>
        <:item as |dd|>
          <dd.Action class="pr-5">
            <Inputs::TeamSelect::Item
              @product={{dd.value}}
              @selected={{dd.selected}}
              @abbreviation={{dd.attrs.abbreviation}}
            />
          </dd.Action>
        </:item>
      </X::DropdownList>
    {{/if}}
  {{else if this.fetchteams.isRunning}}
    <FlightIcon data-test-product-select-spinner @name="loading" />
  {{else}}
    <div
      class="absolute top-0 left-0"
      {{did-insert (perform this.fetchteams)}}
    ></div>
  {{/if}}
</div>

and

second_dropdown.ts

import { assert } from "@ember/debug";
import {action, computed} from "@ember/object";
import { inject as service } from "@ember/service";
import { Placement } from "@floating-ui/dom";
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { task } from "ember-concurrency";
import FetchService from "hermes/services/fetch";
import getProductId from "hermes/utils/get-product-id";

interface InputsTeamSelectSignature {
  Element: HTMLDivElement;
  Args: {
    selectedBU: string | null;
    selected?: string;
    onChange: (value: string, attributes?: TeamArea) => void;
    formatIsBadge?: boolean;
    placement?: Placement;
    isSaving?: boolean;
    renderOut?: boolean;
  };
}

type TeamAreas = {
  [key: string]: TeamArea;
};

export type TeamArea = {
  abbreviation: string;
  perDocDataType: unknown;
  BU: string
};

export default class InputsTeamSelectComponent extends Component<InputsTeamSelectSignature> {
  @service("fetch") declare fetchSvc: FetchService;

  @tracked selected = this.args.selected;

  @tracked teams: TeamAreas | undefined = undefined;
  @tracked selectedBU: string | null = null;

  @computed('args.selectedBU')
  get ReFetchTeams() {
    this.selectedBU = this.args.selectedBU;
    return this.fetchteams.perform();
  }

  get icon(): string {
    let icon = "folder";
    if (this.selected && getProductId(this.selected)) {
      icon = getProductId(this.selected) as string;
    }
    return icon;
  }

  get selectedProductAbbreviation(): string | null {
    if (!this.selected) {
      return null;
    }
    const selectedProduct = this.teams?.[this.selected];
    assert("selected Team must exist", selectedProduct);
    return selectedProduct.abbreviation;
  }

  @action onChange(newValue: any, attributes?: TeamArea) {
    this.selected = newValue;
    this.args.onChange(newValue, attributes);
  }

  // @action onBUChange(){
  //   this.selectedBU = this.args.selectedBU;
  //   this.fetchteams.perform();
  // }

  // protected fetchProducts = task(async () => {
  //   try {
  //     let products = await this.fetchSvc
  //       .fetch("/api/v1/products")
  //       .then((resp) => resp?.json());
  //     this.products = products;
  //   } catch (err) {
  //     console.error(err);
  //     throw err;
  //   }
  // });
    protected fetchteams = task(async () => {
      try {
        // Filter the teams based on the selected business unit
        console.log("parent injected value is: ",this.args.selectedBU)
        let teams = await this.fetchSvc
            .fetch("/api/v1/teams")
            .then((resp) => resp?.json());

        // Filter the teams based on the selected business unit
        const filteredTeams: TeamAreas = {};

        for (const team in teams) {
          if (Object.prototype.hasOwnProperty.call(teams, team)) {
            const teamData: TeamArea | undefined = teams[team];
            if (teamData && teamData.BU  === this.args.selectedBU) {
              filteredTeams[team] = teamData;
            }
          }
        }
        console.log("the filtered teams are: ",filteredTeams);
        this.teams = filteredTeams;
          console.log(this.teams);
        } catch (err) {
          console.error(err);
          throw err;
        }
      });

}

declare module "@glint/environment-ember-loose/registry" {
  export default interface Registry {
    "Inputs::TeamSelect": typeof InputsTeamSelectComponent;
  }
}

Any, sort of guidance will be highly appreciated! Thanks!

I have tried using @traked, @did-update etc, but nothing seems to work, even if I remove the "did-insert" the dropdown disappears completely.


Solution

  • Thanks for posting such a detailed question with example code!

    I think the answer here is derived data (which you're already doing! but something is just a little bit off). (For observers of this answer) what "derived data" means is that, we need to define "what it means to have a value", and then each select derives based on that.

    There are two major things I noticed in your code snippet

    Since the code you've provided is specific to your app (and I can't run in), I've made a (simplified) demo which shows the situation you described (and again, thanks for the detailed description of expected behavior).

    I've used the open StarWars API to simulate requesting data that these selects would populate from.

    The interactive version is here

    And here is the relevant code:

      <label>
        Select {{selectedAPI.current}}
    
        {{! 
          it's important to model async behavior, and this util makes that a bit easier. 
          Docs here: https://ember-resources.pages.dev/funcs/util_remote_data.RemoteData }}
        {{#let (RemoteData (urlForDataSource selectedAPI.current)) as |request|}}
          {{#if request.isLoading}}
            Loading...
          {{/if}}
    
          {{#if request.value}}
             <Select 
               @options={{names request.value.results}} 
               @onChange={{(fn setSelected selectedAPI.current)}} 
              >
                <:option as |item|>
                  {{item.name}}
                </:option>
             </Select>
          {{/if}}
        {{/let}}
      </label>
    

    Some notes: