DEV Community

Andrej Tlčina
Andrej Tlčina

Posted on

Building Training Plan builder: DnD & Autocomplete

Hello! In the previous article I promised to talk about something more interesting. I was always curious about Drag and Drop functionality and I wanted to build something, which is going to use it as a feature. So, I came up with an app that helps you build a training plan by looking up different exercises. A plan consists of multiple phases, displayed as rectangle columns. I made up a scenario where the user will search for exercise. Upon selecting one, it will be appended into the special non-phase column. Users can then, drag this exercise from the non-phase column to any other. Exercises will be displayed as simple rectangles with a name, a button for removing a given exercise, and two inputs for filling out sets and reps. So let's start building.

Autocomplete

I'd like to start things off by first creating autocomplete component, so we get the exercise options. For the component, I'll be using a library called Downshift. It is a headless UI component, which means I'll have all the needed functionality and state management, but no UI, which is a great pattern. Sometimes these component libraries can be too rigid that you can't customize them, this however gives you as much freedom as you want. I pretty much looked at the docs and followed along. The stuff that I added was fetching data from an external API. For that, I used debounce, so we don't fetch on every key press, but after some time of inactivity. However there's a catch with debounce in React, you have to use it in useCallback (more on that here). Also, when there are no exercises found I display the "No results" option in the list of options. The component will look like this

const NO_OPTIONS_OPT = {
  value: "NO_OPTIONS",
  data: {
    id: 0,
    name: "NO_OPTIONS",
    category: "",
    image: null,
    image_thumbnail: null,
  },
  info: { sets: 0, reps: 0 },
};

const getExerciseFilter =
  (inputValue: string | undefined) => (item: ExerciseSuggestion) => {
    return !inputValue || item.value.includes(inputValue);
  };

const AutoComplete = (props: {
  setSearchedExercises: (r: ExerciseSuggestion[]) => void;
}) => {
  const { setSearchedExercises } = props;

  const [items, setItems] = React.useState<ExerciseSuggestion[]>([]);

  const onInputValueChange = React.useCallback(
    debounce(
      async ({ inputValue }: UseComboboxStateChange<ExerciseSuggestion>) => {
        const { suggestions } = await searchExercises(inputValue || "");

        if (!suggestions || !suggestions.length) {
          setItems([NO_OPTIONS_OPT]);
        } else {
          const filteredExercises = suggestions.filter(
            getExerciseFilter(inputValue)
          );
          setItems(filteredExercises);
        }
      },
      1000
    ),
    []
  );

  const {
    isOpen,
    getLabelProps,
    getMenuProps,
    getInputProps,
    getComboboxProps,
    highlightedIndex,
    getItemProps,
    selectedItem,
  } = useCombobox({
    onInputValueChange,
    items,
    itemToString(item) {
      return item ? item.value : "";
    },
    onSelectedItemChange(changes) {
      const exercise = changes.selectedItem;
      if (exercise) setSearchedExercises([exercise]);
    },
  });

  return (
    <div
      {...getComboboxProps()}
      className="form-control relative w-72 flex flex-col gap-1"
    >
      <label {...getLabelProps()} className="label label-text">
        Search for exercise
      </label>
      <input {...getInputProps()} className="input input-bordered w-full" />
      <ul
        {...getMenuProps()}
        className="absolute top-24 w-72 bg-white shadow-md max-h-80 overflow-y-auto"
      >
        {isOpen
          ? items.map((item, index) => {
              if (item.value === NO_OPTIONS_OPT.value) {
                return <li className="w-full">No results...</li>;
              }

              return (
                <li
                  key={item.value}
                  className="w-full cursor-pointer"
                  {...getItemProps({
                    key: item.value,
                    index,
                    item,
                    style: {
                      backgroundColor:
                        highlightedIndex === index ? "lightgray" : "white",
                      fontWeight: selectedItem === item ? "bold" : "normal",
                    },
                  })}
                >
                  {item.value}
                </li>
              );
            })
          : null}
      </ul>
    </div>
  );
};

export default AutoComplete;
Enter fullscreen mode Exit fullscreen mode

This component has "a lot of state" and Remix will try to render it on the server which will cause errors/warnings of mismatching ids. To avoid that, we have to save the file as AutoComplete.client.tsx. This still won't fix the problem, because now the component will be rendered as undefined, to render it only on the client we have to install a package called remix-utils. This package will provide us with a ClientOnly utility, which will make sure the component gets rendered only on the client. So, we'll use AutoComplete component like this

<ClientOnly fallback={<div>Loading...</div>}>
  {() => <AutoComplete setSearchedExercises={...} />}
</ClientOnly>
Enter fullscreen mode Exit fullscreen mode

Drag and Drop

For Drag and Drop I used the component library beautiful-dnd. I was sold on the fact that it gives me the exact functionality I needed and it's headless.
The library gives us three main elements

  • DragDropContext (context holding all of the values)
  • Droppable (the container holding draggable elements)
  • Draggable (the element user drags)

The general structure when working with beautiful-dnd will look like this

<DragDropContext onDragEnd={...}>
 <Droppable>
   <Draggable />
   <Draggable />
   ...
 </Droppable>
 <Droppable>
   <Draggable />
   <Draggable />
   ...
 </Droppable>
 ...
</DragDropContext>
Enter fullscreen mode Exit fullscreen mode

For state I'm going to keep 2 state variables:

  • elements of phase columns
  • elements of search column
const getItems = (phases: TPhases) => {
  const result = [];
  for (let index = 0; index < phases.length; index++) {
    const phase = phases[index];
    const exercises = phase.exercises.map((el) => ({
      name: el.name,
      id: el.id,
      info: { reps: el.exerciseData.reps, sets: el.exerciseData.sets },
    }));
    result.push([...exercises]);
  }
  return result;
};

const [phases, setPhases] = React.useState<DndExercise[][]>(
    getItems(initialPhases)
  );

const [searchedExercises, setSearchedExercises] = React.useState<
    DndExercise[]
  >([]);
Enter fullscreen mode Exit fullscreen mode

Now, we can also update Autocomplete component by passing a props which will set searchedExercises

<AutoComplete
  setSearchedExercises={(e) => {
  const newlyAdded: DndExercise[] = [
    {
       name: e[0].value,
       id: String(e[0].data.id),
       info: { sets: 0, reps: 0 },
    },
  ];
  setSearchedExercises((prevStat) => [...prevStat, ...newlyAdded]);
  }}
/>
Enter fullscreen mode Exit fullscreen mode

Also, each Droppable should have an id which is going to help with moving elements. So, we can rewrite the general code as

<DragDropContext onDragEnd={...}>
 {
  phases.map((phase, idx) => {
    return (
      <Droppable id={idx}>
        {phase.exercises.map(e => {
          return <Draggable>some content</Draggable>
        }
      </Droppable>
    )
  }
 }
 <Droppable id={phases.length}>
    {searchExercises.map(e => {
      return <Draggable>some content</Draggable>
    }
 </Droppable>
</DragDropContext>
Enter fullscreen mode Exit fullscreen mode

Now, for the hardest part, moving elements. The onDragEnd gives you the result object as an argument that holds important things like source ID (from which column we're dragging out) and destination ID (into which column we're dragging to).

const onDragEnd = (result: DropResult) => {
    const { source, destination } = result;

    // dropped outside the list
    if (!destination) {
      return;
    }
    const sInd = +source.droppableId;
    const dInd = +destination.droppableId

    // move and reorder elements
}
Enter fullscreen mode Exit fullscreen mode

For moving and reordering elements I'm using functions you can find in the beautiful-dnd examples. You can look at how they work, they're quite simple. I used them as these black box functions which need some input and return some output.
There are a couple of scenarios that can happen. I made a little diagram, which will hopefully explain it better than a paragraph of text.

Image description

In code:

  const onDragEnd = (result: DropResult) => {
    const { source, destination } = result;

    // dropped outside the list
    if (!destination) {
      return;
    }
    const sInd = +source.droppableId;
    const dInd = +destination.droppableId;

    if (sInd === dInd) {
      if (sInd === phases.length) {
        const newItems = reorder(
          searchedExercises,
          source.index,
          destination.index
        );
        setSearchedExercises(newItems);
      } else {
        const newItems = reorder(phases[sInd], source.index, destination.index);
        const newState = [...phases];
        newState[sInd] = newItems;
        setPhases(newState);
      }
    } else {
      if (sInd === phases.length) {
        const result = move(
          searchedExercises,
          phases[dInd],
          source,
          destination
        );
        const newState = [...phases];
        newState[dInd] = result[dInd];
        setPhases(newState);
        const newSearchedExercises = result[sInd];
        setSearchedExercises(newSearchedExercises);
      } else if (dInd === phases.length) {
        const result = move(
          phases[sInd],
          searchedExercises,
          source,
          destination
        );
        const newState = [...phases];
        newState[sInd] = result[sInd];
        setPhases(newState);
        const newSearchedExercises = result[dInd];
        setSearchedExercises(newSearchedExercises);
      } else {
        const result = move(phases[sInd], phases[dInd], source, destination);
        const newState = [...phases];
        newState[sInd] = result[sInd];
        newState[dInd] = result[dInd];
        setPhases(newState);
      }
    }
  };
Enter fullscreen mode Exit fullscreen mode

I'm storing all the phase values in state variables. Meaning phase attributes like name, description, but also phase exercise attributes. I mentioned each displayed exercise will have two inputs, so the user can set sets and reps. I made a function for changing these values in state variables. I hooked it up on blur. Depending on column's id it changes the contents of a particular state variable.

const changeSetsAndReps = (
    value: number,
    what: "sets" | "reps",
    whichCol: number,
    whichRow: number
  ) => {
    if (whichCol === phases.length) {
      const copied = [...searchedExercises];
      copied[whichRow] = {
        ...copied[whichRow],
        info: {
          ...copied[whichRow]["info"],
          [what]: value,
        },
      };
      setSearchedExercises(copied);
    } else {
      const copied = [...phases];
      copied[whichCol][whichRow] = {
        ...copied[whichCol][whichRow],
        info: {
          ...copied[whichCol][whichRow]["info"],
          [what]: value,
        },
      };
      setPhases(copied);
    }
  };
Enter fullscreen mode Exit fullscreen mode

In the end, I just implemented remove functionality, depending on if the displayed exercise is in the search column or phase column I call either:

  const removeSearched = (exerciseIdx: number) => {
    const newSearched = [...searchedExercises];

    newSearched.splice(exerciseIdx, 1);

    setSearchedExercises(newSearched);
  };
Enter fullscreen mode Exit fullscreen mode

or


  const removeExercise = (phaseIdx: number, exerciseIdx: number) => {
    const newPhases = [...phases];

    newPhases[phaseIdx].splice(exerciseIdx, 1);

    setPhases(newPhases);
  };
Enter fullscreen mode Exit fullscreen mode

If you were to test the functionality of this, you'd find that when inputting some value (sets/reps) and trying to drag the element it will set that value to 0.

Image description

It's because of that blur listener. To avoid this, I created a ref

const inFocus = React.useRef<HTMLInputElement>();
Enter fullscreen mode Exit fullscreen mode

which I'm setting on input focus like this

<input onFocus={(e) => (inFocus.current = e.target)} />
Enter fullscreen mode Exit fullscreen mode

, then, I just add prop to DragDropContext

<DragDropContext
  onBeforeDragStart={() => inFocus.current?.blur()}
  ... 
Enter fullscreen mode Exit fullscreen mode

Conclusion

In the end, we can look up exercises and drag and drop them into particular phases. But, that's not the end cause we have to submit them to the backend. Will do that in the next part. See you there 😉

Top comments (0)