Combining Generators and JSX

Hi all,

I wanted to share a solution I built for using a JSX component in a Framework markdown file combined with Generators in order to build arbitrary controls that provide reactivity similar to the Inputs that come with Framework.

I’m not sure if this was the best approach (and would love to hear if anyone has ideas for optimizations) but it wasn’t immediately obvious to me how to combine these at first, so maybe someone else will benefit from this.

Our goal was to have a set of 3 linked select inputs, each one populated by an array. The array in the 2nd select would be filtered based on the selected value in the first select, and the 3rd select array would be filtered based on the selected value in the second select.

Changing a select higher in the chain would also reset the ones below.

Now, the selected values from the JSX need to be available in the global scope so they can be used to filter other components in the markdown file, so we used Generators to make them reactive.

Here’s our starting data (simplified):

const vps = [ 
  { name: 'VP Name 1', id: 123, mgrId: 0 }, 
  { name: 'VP Name 2', id: 124, mgrId: 0 } 
];
const directors = [ 
  { name: 'Dir Name 1', id: 125, mgrId: 123 }, 
  { name: 'Dir Name 2', id: 126, mgrId: 124 } 
];
const managers = [ 
  { name: 'Mgr Name 1', id: 127, mgrId: 125 },
  { name: 'Mgr Name 2', id: 128, mgrId: 126 } 
];

Here are the Generators in the Markdown file:

const selectedVp = Generators.observe((change) => {
    const vpUp = (evt) => change(evt.detail.value);
    document.addEventListener('vpSelectChange', vpUp)
    change(0);
    return () => removeEventListener('vpSelectChange', vpUp)
});

const selectedDirector = Generators.observe((change) => {
    const dirUp = (evt) => change(evt.detail.value);
    document.addEventListener('directorSelectChange', dirUp)
    change(0);
    return () => removeEventListener('directorSelectChange', dirUp)
});

const selectedManager = Generators.observe((change) => {
    const mgrUp = (evt) => change(evt.detail.value);
    document.addEventListener('managerSelectChange', mgrUp)
    change(0);
    return () => removeEventListener('managerSelectChange', mgrUp)
});

Here’s the JSX file:

It accepts an array of selects and will create any number of them (in our case it’s 3, but you could pass in 2, or more than 3, etc)…

import React, { useRef, useState } from 'npm:react';

export function MultiSelect( { selects } ) {

    const selectState = [];
    const selectRefs = [];

    selects.forEach((select) => {
        selectRefs.push(useRef(null));
        selectState.push(useState(select.value));
    })

    const handleSelectChange = (event, index) => {
        selectState[index][1](event.target.value);
        document.dispatchEvent(new CustomEvent(selects[index].onChangeEventName, { detail: { value: event.target.value } }));
        for (let i = index + 1; i < selects.length; i++) {
            selectState[i][1](selects[i].defaultOption.value);
            document.dispatchEvent(new CustomEvent(selects[i].onChangeEventName, { detail: { value: selects[i].defaultOption.value } }));
        }
    }

    return (
    <div>
        {selects.map((select, index) => (
          <select ref={selectRefs[index]} id={`select${index}`} value={selectState[index][0]} onChange={(event) => handleSelectChange(event, index)}>
             {[select.defaultOption, ...select.options].map((option) => (
                <option key={option.value} value={option.value}>{option.label}</option>
             ))}
          </select>
        ))}
     </div>
      );
}

As part of the component’s handleSelectChange(), we fire a custom event that the generators we declared in the Markdown file are listening for (see below for where the event name is passed into the JSX), which is how we trigger an update to the globally-scoped variables outside of the JSX component.

And finally here’s how the JSX component is called and rendered in the Markdown file:

display(<MultiSelect selects={[
    {
        defaultOption: { label: 'Select a VP', value: 0 },
        options: vps.map((vp) => ({ 
            value: vp.id, 
            label: vp.name
        })),
        value: 0,
        onChangeEventName: 'vpSelectChange'
    },
    {
        defaultOption: { label: 'Select a director', value: 0 },
        options: directors.filter((d) => d.mgrId == selectedVp).map((dir) => ({
            value: dir.id, 
            label: dir.name
        })),
        value: 0,
        onChangeEventName: 'directorSelectChange'
    },
    {
        defaultOption: { label: 'Select a manager', value: 0 },
        options: managers.filter((m) => m.mgrId == selectedDirector).map((mgr) => ({
            value: mgr.id, 
            label: mgr.name
        })),
        value: 0,
        onChangeEventName: 'managerSelectChange'
    }
]} />)

Because selectedVp, selectedDirector and selectedManager are all driven by generators, whenever they change, it refreshes the options array which updates the JSX component but also any other component in your Markdown file that relies on them.

In our case, we have a big list of hundreds programs that have a managerId property and we filter that list based on the selected values in the drop-downs… So as the user drills down by VP, Director and Manager, the chart displaying the programs narrows its scope to less and less programs.

Anyhow, this was a fun exploration in how to combine the reactivity of JSX components with Generators.

1 Like

Thanks for sharing! Great topic. :+1:

I would also consider using Mutable to achieve something similar to React’s useState and “Lifting State Up”.

First, a useState helper:

function useState(initialValue) {
  const state = Mutable(initialValue);
  const setState = (value) => (state.value = value);
  return [state, setState];
}

Then declare your desired top-level state variables and their corresponding initial value and mutator:

const [selectedVp, setSelectedVp] = useState(0);
const [selectedDirector, setSelectedDirector] = useState(0);
const [selectedManager, setSelectedManager] = useState(0);

Here is a Select JSX component that functions similarly to your MultiSelect, but for a single select:

function Select({ value, options, setValue }) {
  return (
    <select value={value} onChange={(event) => setValue(options[event.target.selectedIndex].value)}>
      {options.map((option) => (
        <option key={option.value} value={option.value}>
          {option.label}
        </option>
      ))}
    </select>
  );
}

Lastly, you can display your three selects like so:

display(
  <div>
    <Select
      value={selectedVp}
      options={[
        { label: "Select a VP", value: 0 },
        ...vps.map((vp) => ({ value: vp.id, label: vp.name })),
      ]}
      setValue={setSelectedVp}
    />
    <Select
      value={selectedDirector}
      options={[
        { label: "Select a director", value: 0 },
        ...directors
          .filter((d) => d.mgrId === selectedVp)
          .map((dir) => ({ value: dir.id, label: dir.name })),
      ]}
      setValue={setSelectedDirector}
    />
    <Select
      value={selectedManager}
      options={[
        { label: "Select a manager", value: 0 },
        ...managers
          .filter((m) => m.mgrId === selectedDirector)
          .map((mgr) => ({ value: mgr.id, label: mgr.name })),
      ]}
      setValue={setSelectedManager}
    />
  </div>,
);

I noticed a small issue where selecting a new director should clear the selected manager, and selecting a new VP should clear both the director and manager… but you could fix that by changing the implementation of setSelectedVp or setSelectedDirector, or you could put some extra logic in the setValue props.

Hope this helps!

2 Likes

Interesting! I totally missed Mutable when I was reading the Reactivity docs I guess…

This is much cleaner and simpler, I should probably refactor my code… :slight_smile:

Thank you!

1 Like