Построитель учебных планов: DnD и автозаполнение

Здравствуйте! В предыдущей статье я обещал рассказать о чем-то более интересном. Мне всегда была интересна функциональность Drag and Drop, и я хотел создать что-то, что будет использовать ее в качестве функции. Итак, я придумал приложение, которое поможет вам построить план тренировок, просматривая различные упражнения. План состоит из нескольких этапов, отображаемых в виде прямоугольных столбцов. Я придумал сценарий, в котором пользователь будет искать упражнения. После выбора одного из них оно будет добавлено в специальный столбец, не относящийся к фазе. Затем пользователь может перетащить это упражнение из колонки без фазы в любую другую. Упражнения будут отображаться в виде простых прямоугольников с названием, кнопкой для удаления упражнения и двумя входами для заполнения сетов и повторений. Итак, давайте начнем строить.

Автозаполнение

Я бы хотел начать с создания компонента автозаполнения, чтобы мы получили варианты упражнений. Для компонента я буду использовать библиотеку под названием Downshift. Это компонент без UI, что означает, что у меня будет вся необходимая функциональность и управление состоянием, но без UI, что является отличным паттерном. Иногда эти библиотеки компонентов могут быть слишком жесткими, и вы не можете их настроить, но эта библиотека дает вам столько свободы, сколько вы хотите. Я практически полностью изучил документацию и следовал ей. Единственное, что я добавил, это получение данных из внешнего API. Для этого я использовал debounce, так что мы получаем данные не при каждом нажатии клавиши, а после некоторого времени бездействия. Однако с debounce в React есть одна загвоздка — его нужно использовать в useCallback (подробнее об этом здесь). Также, когда упражнений не найдено, я отображаю опцию «Нет результатов» в списке опций. Компонент будет выглядеть следующим образом

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;
Вход в полноэкранный режим Выход из полноэкранного режима

У этого компонента «много состояния», и Remix попытается отрисовать его на сервере, что вызовет ошибки/предупреждения о несовпадении идентификаторов. Чтобы избежать этого, мы должны сохранить файл как AutoComplete.client.tsx. Это все равно не решит проблему, потому что теперь компонент будет отображаться как неопределенный. Чтобы отобразить его только на клиенте, мы должны установить пакет remix-utils. Этот пакет предоставит нам утилиту ClientOnly, которая обеспечит рендеринг компонента только на клиенте. Итак, мы будем использовать компонент AutoComplete следующим образом

<ClientOnly fallback={<div>Loading...</div>}>
  {() => <AutoComplete setSearchedExercises={...} />}
</ClientOnly>
Вход в полноэкранный режим Выйти из полноэкранного режима

Перетаскивание и падение

Для Drag and Drop я использовал библиотеку компонентов beautiful-dnd. Я купился на то, что она дает именно ту функциональность, которая мне нужна, и она безголовая.
Библиотека предоставляет нам три основных элемента

  • DragDropContext (контекст, содержащий все значения)
  • Droppable (контейнер, содержащий перетаскиваемые элементы)
  • Draggable (элемент, который пользователь перетаскивает).

Общая структура при работе с beautiful-dnd будет выглядеть следующим образом

<DragDropContext onDragEnd={...}>
 <Droppable>
   <Draggable />
   <Draggable />
   ...
 </Droppable>
 <Droppable>
   <Draggable />
   <Draggable />
   ...
 </Droppable>
 ...
</DragDropContext>
Вход в полноэкранный режим Выход из полноэкранного режима

Для состояния я собираюсь хранить 2 переменные состояния:

  • элементы колонок фаз
  • элементы колонки поиска
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[]
  >([]);
Войти в полноэкранный режим Выход из полноэкранного режима

Теперь мы также можем обновить компонент Autocomplete, передав ему props, который установит 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]);
  }}
/>
Войти в полноэкранный режим Выйти из полноэкранного режима

Также, каждый Droppable должен иметь id, что поможет при перемещении элементов. Таким образом, мы можем переписать общий код следующим образом

<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>
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь перейдем к самой сложной части — перемещению элементов. Функция onDragEnd дает вам объект result в качестве аргумента, который содержит такие важные вещи, как ID источника (из какой колонки мы перетаскиваем) и ID назначения (в какую колонку мы перетаскиваем).

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
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для перемещения и переупорядочивания элементов я использую функции, которые вы можете найти в примерах beautiful-dnd. Вы можете посмотреть, как они работают, они довольно просты. Я использовал их как функции «черного ящика», которым нужен некоторый вход и которые возвращают некоторый выход.
Есть несколько сценариев, которые могут произойти. Я сделал небольшую диаграмму, которая, надеюсь, объяснит все лучше, чем абзац текста.

В коде:

  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);
      }
    }
  };
Войти в полноэкранный режим Выход из полноэкранного режима

Я храню все значения фаз в переменных состояния. Имеются в виду атрибуты фазы, такие как имя, описание, а также атрибуты упражнений фазы. Я упоминал, что каждое отображаемое упражнение будет иметь два входа, так что пользователь может задавать сеты и повторения. Я создал функцию для изменения этих значений в переменных состояния. Я подключил ее к blur. В зависимости от id столбца она изменяет содержимое определенной переменной состояния.

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);
    }
  };
Вход в полноэкранный режим Выход из полноэкранного режима

В итоге я просто реализовал функцию remove, в зависимости от того, находится ли отображаемое упражнение в колонке поиска или в колонке фазы, я вызываю либо то, либо другое:

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

    newSearched.splice(exerciseIdx, 1);

    setSearchedExercises(newSearched);
  };
Войти в полноэкранный режим Выйти из полноэкранного режима

или


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

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

    setPhases(newPhases);
  };
Вход в полноэкранный режим Выход из полноэкранного режима

Если бы вы проверили функциональность этого, то обнаружили бы, что при вводе некоторого значения (sets/reps) и попытке перетащить элемент он установит это значение равным 0.

Это происходит из-за этого слушателя размытия. Чтобы избежать этого, я создал ref

const inFocus = React.useRef<HTMLInputElement>();
Вход в полноэкранный режим Выход из полноэкранного режима

который я устанавливаю при фокусе ввода следующим образом

<input onFocus={(e) => (inFocus.current = e.target)} />
Ввести полноэкранный режим Выйти из полноэкранного режима

Затем, я просто добавляю свойство в DragDropContext

<DragDropContext
  onBeforeDragStart={() => inFocus.current?.blur()}
  ... 
Войти в полноэкранный режим Выйти из полноэкранного режима

Заключение

В итоге мы можем искать упражнения и перетаскивать их в определенные фазы. Но это еще не все, потому что нам нужно отправить их в бэкенд. Мы сделаем это в следующей части. Увидимся там 😉

Оцените статью
devanswers.ru
Добавить комментарий