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;
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>
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>
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[]
>([]);
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]);
}}
/>
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>
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
}
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.
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);
}
}
};
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);
}
};
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);
};
or
const removeExercise = (phaseIdx: number, exerciseIdx: number) => {
const newPhases = [...phases];
newPhases[phaseIdx].splice(exerciseIdx, 1);
setPhases(newPhases);
};
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.
It's because of that blur listener. To avoid this, I created a ref
const inFocus = React.useRef<HTMLInputElement>();
which I'm setting on input focus like this
<input onFocus={(e) => (inFocus.current = e.target)} />
, then, I just add prop to DragDropContext
<DragDropContext
onBeforeDragStart={() => inFocus.current?.blur()}
...
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)