Zac Fukuda
053

How to Create a Daily Scheduler with React in TypeScript - Part 3
Update, Remove, Load Data

This is the part three of tutorial on how to create a simple front-end daily scheduler with React in TypeScript. In this article I am going to show you how to add an UPDATE, REMOVE and initial data loading features to our scheduler app.

The daily scheduler looks like this:

You can see the live demo.

The tutorial is divided into three parts:

  1. Introduction and preparation
  2. Add and list
  3. Update, remove, and load data

The source code of this tutorial is available at GitHub. The source code at the end of this article is saved as 03-update-remove directory.

This article assumes that readers have followed the part one and two of this tutorial.

Updating

We want to update each schedule by dragging either left or right side of rectangular schedule UI Schedule.

Drag left/right side of schedule

Now, please open DailyScheduler.tsx and:

  1. import a new function joinSchedulesOnUpdate from daily-scheduler-util
  2. add a function updateSchedule to the component
  3. pass that function to Schedulers as an updateSchedule property
./daily-scheduler/DailyScheduler.tsx
…
import {
  …
  joinSchedulesOnUpdate,
} from "./daily-scheduler-util";
…

export default function DailyScheduler(…): JSX.Element {
  …

  function updateSchedule(schedule: ScheduleItemIndexable): void {
    const ss = joinSchedulesOnUpdate(schedule);
    setSchedules(ss);
  }

  …

  return (
    <div className="daily-scheduler">
      …
      <div className="track">
        …
        <Schedules items={schedules} updateSchedule={updateSchedule} />
      </div>
    </div>
  );
}

And let Scheduler pass down the new property/function updateSchedule to Schedule:

./daily-scheduler/Schedules.tsx
…

export default function Schedules({
  …
  updateSchedule,
}: {
  …
  updateSchedule(arg0: ScheduleItemIndexable): void;
}) {
  return (
    <>
      {items.map((i, index) => (
        <Schedule
          …
          updateSchedule={updateSchedule}
          …
        />
      ))}
    </>
  );
}

The Schedule is almost a new component. So replace its code with the code below:

./daily-scheduler/Schedule.tsx
import { useEffect, useState, useRef } from "react";
import {
  getDragging,
  setDragPosition,
  dragStart,
  dragEnd,
  getStartAndEndOnSchedule,
  setHorizonOnEntry,
} from "./daily-scheduler-util";
import type { MouseEvent as ReactMouseEvent } from "react";

interface ScheduleProps extends ScheduleItemIndexable {
  updateSchedule(arg0: ScheduleItemIndexable): void;
}

export default function Schedule({
  id,
  start,
  end,
  category,
  category_id,
  index,
  updateSchedule,
}: ScheduleProps) {
  const [left, setLeft] = useState(0);
  const [right, setRight] = useState(0);
  const ref = useRef<HTMLDivElement>(null);
  const style = {
    backgroundColor: category.fill,
    borderColor: category.stroke,
    left: start,
    width: end - start,
  };

  function isLeftSide(cx: number): boolean {
    return cx > left && cx < left + 10;
  }

  function isCenter(cx: number): boolean {
    return cx >= left + 10 && cx <= right - 10;
  }

  function isRightSide(cx: number): boolean {
    return cx > right - 10 && cx < right;
  }

  function handleMouseDown({ clientX }: ReactMouseEvent): void {
    if (isLeftSide(clientX)) setDragPosition(1);
    else if (isRightSide(clientX)) setDragPosition(2);

    dragStart(clientX);
    setHorizonOnEntry(clientX);
    document.body.style.cursor = "col-resize";
    window.addEventListener("mousemove", handleWindowMouseMove);
    window.addEventListener("mouseup", handleWindowMouseUp);
  }

  function handleMouseMove(e: ReactMouseEvent): void {
    if (getDragging()) return;

    const current = ref.current as HTMLDivElement;

    if (isCenter(e.clientX)) current.style.cursor = "";
    else current.style.cursor = "col-resize";
  }

  function handleWindowMouseMove(e: MouseEvent): void {
    const o = getStartAndEndOnSchedule(start, end, e.clientX);
    const target = ref.current as HTMLDivElement;
    target.style.left = o.start + "px";
    target.style.width = o.end - o.start + "px";
  }

  function handleWindowMouseUp(e: MouseEvent): void {
    const o = getStartAndEndOnSchedule(start, end, e.clientX);

    dragEnd();
    document.body.style.cursor = "";
    window.removeEventListener("mousemove", handleWindowMouseMove);
    window.removeEventListener("mouseup", handleWindowMouseUp);

    updateSchedule({
      id,
      start: o.start,
      end: o.end,
      category,
      category_id,
      index,
    });
  }

  useEffect(() => {
    const current = ref.current as HTMLDivElement;
    const { left, right } = current.getBoundingClientRect();
    setLeft(left);
    setRight(right);
  }, [start, end]);

  return (
    <div
      ref={ref}
      className="schedule-item"
      style={style}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
    ></div>
  );
}

Then, add new functions to daily-scheduler-util:

./daily-scheduler/daily-scheduler-util.ts
export function joinSchedulesOnUpdate(
  schedule: ScheduleItemIndexable
): ScheduleItem[] {
  const { index } = schedule;
  let modified = false;

  for (let i = 0; i < schedules.length; i++) {
    if (schedule.category_id !== schedules[i].category_id) continue;
    if (schedule.end === schedules[i].start) {
      schedules[i].start = schedule.start;
      modified = true;
      break;
    }
    if (schedule.start === schedules[i].end) {
      schedules[i].end = schedule.end;
      modified = true;
      break;
    }
  }

  if (modified) {
    addTrash(schedules[index]);
    schedules.splice(index, 1);
  } else {
    const { index, ...newSchedule } = schedule;
    schedules[index] = newSchedule;
  }

  return [...schedules];
}

export function getDragging(): boolean {
  return dragging;
}

export function getStartAndEndOnSchedule(
  start: number,
  end: number,
  x2: number
): { start: number; end: number } {
  x1 = x1 as number;
  minX = minX as number;
  maxX = maxX as number;
  x2 = o + snapToGuide(x2 - o);
  const dx = x2 - x1;

  if (dragPosition === 1) {
    start += dx;

    if (start < minX) start = minX;
    if (start > maxX) start = maxX;
  } else if (dragPosition === 2) {
    end += dx;

    if (end < minX) end = minX;
    if (end > maxX) end = maxX;
  }

  return { start, end };
}

export function setHorizonOnEntry(x: number): void {
  minX = 0;
  maxX = guides[guides.length - 1];

  const start = x - o;
  let c = schedules[0];

  function isCurrent(schedule: ScheduleItem): boolean {
    const _start = schedule.start;
    const _end = schedule.end;

    return start >= _start && start <= _end;
  }

  if (dragPosition === LEFT) {
    for (let i = 0; i < schedules.length; i++) {
      if (isCurrent(schedules[i])) {
        c = schedules[i];
        continue;
      }
      if (start <= schedules[i].start) continue;
      if (minX <= schedules[i].end) minX = schedules[i].end;
    }

    const i = guides.indexOf(c.end);
    maxX = guides[i - 1];
  } else if (dragPosition === RIGHT) {
    for (let i = 0; i < schedules.length; i++) {
      if (isCurrent(schedules[i])) {
        c = schedules[i];
        continue;
      }
      if (start >= schedules[i].end) continue;
      if (maxX >= schedules[i].start) maxX = schedules[i].start;
    }

    const i = guides.indexOf(c.start);
    minX = guides[i + 1];
  }
}

The joinSchedulesOnUpdate() is a function that joins two schedules in the same category next to each other, designated for update.

The difference from joinSchedules() we created for adding is that on update the updated schedule must have one schedule in the same category, at max, next to itself since we can modify one schedule only to one direction. Meanwhile, on add, the new category can have two schedules in the same category on both side.

Joining schedules on add
Join schedules on add
Joining schedules on update
Join schedules on update

There must be a logic which handles both situation, which I gave up coming up for simplicity and time reason.

The business logic of updating resembles that of adding. The difference is that we have to know which side of schedule is being dragged. The two functions isLeftSide and isRightSide do this job. Once we know that, we calculate minX/maxX coordinates that the schedule can extend or shrink and start/end coordinates as mousemove, set the left and width styles of Schedule accordingly.

If you run the application now, you can drag the side of each schedule to right or left.

Updating schedule

Removing

We remove schedules by clicking/selecting one item and press DELETE(Backspace) key. First:

  1. import addTrash from daily-scheduler-util
  2. add a function for remove to DailyScheduler
  3. pass that function to Schedules as property
./daily-scheduler/DailyScheduler.tsx
…
import {
  …
  addTrash,
} from "./daily-scheduler-util";
…

export default function DailyScheduler(…): JSX.Element {
  …

  function removeSchedule(index: number): void {
    addTrash(schedules[index]);
    schedules.splice(index, 1);
    setSchedules([...schedules]);
  }

  return (
    <div className="daily-scheduler">
      …
      <div className="track">
        …
        <Schedules
          …
          removeSchedule={removeSchedule}
        />
      </div>
    </div>
  );
}

And let Schedules pass down the new property/function removeSchedule to Schedule:

…
export default function Schedules({
  …
  removeSchedule,
}: {
  …
  removeSchedule(arg0: number): void;
}) {
  return (
    <>
      {items.map((i, index) => (
        <Schedule
          …
          removeSchedule={removeSchedule}
          {...i}
        />
      ))}
    </>
  );
}

We can think of three situations when one schedule is moused down. The two of those are when either left of right side of the schedule is moused down, which trigger update event. The another is neither left of right side—i.e. middle part—of the schedule is moused down.

We want selection event to trigger only in the last situation. So we add one more condition to handleMouseDown(), which invokes removing event:

./daily-scheduler/Schedule.tsx
…

const cnScheduleFocus = "focused";

interface ScheduleProps extends ScheduleItemIndexable {
  …
  removeSchedule(arg0: number): void;
}

export default function Schedule({
  …
  removeSchedule,
}: ScheduleProps) {
  …

  function handleKeyDown(e: KeyboardEvent): void {
    if (e.keyCode === 8 || e.key === "Backspace") {
      window.onkeydown = null;
      ref.current?.classList.remove(cnScheduleFocus);

      return removeSchedule(index);
    }
  }

  function focus(): void {
    const current = ref.current as HTMLDivElement;
    const parentNode = current.parentNode as HTMLDivElement;
    const nodes = parentNode.querySelectorAll("." + cnScheduleFocus);

    nodes.forEach((i) => i.classList.remove(cnScheduleFocus));
    current.classList.add(cnScheduleFocus);

    window.onkeydown = handleKeyDown;
  }

  function unfocus(): void {
    ref.current?.classList.remove(cnScheduleFocus);
    window.onkeydown = null;
  }

  function handleClick(): void {
    if (ref.current?.classList.contains(cnScheduleFocus)) {
      return unfocus();
    }

    focus();
  }

  function handleMouseDown(…): void {
    if (isLeftSide(clientX)) …
    else if (isRightSide(clientX)) …
    else return handleClick();

    …
  }

  …
}

Now you can remove schedules.

Removing schedules - focus
Select schedule
Removing schedules - presskey
Press DELETE

Newly created schedules don’t have ID. Upon removing, they won’t be added to trash which supposedly be handled by the backend which IDs of schedules to be deleted from database.

Loading Initial Data

In practical application, the schedule data should be fetched from database. This article focuses only on the front-end. So let’s just write in initialSchedules to App.tsx, and pass that initial data to DailyScheduler:

./App.tsx
…

const initialSchedules = [
  {
    id: 1,
    start: "2024-01-01 00:00:00",
    end: "2024-01-01 07:00:00",
    category_id: 1,
  },
  {
    id: 2,
    start: "2024-01-01 09:00:00",
    end: "2024-01-01 12:30:00",
    category_id: 2,
  },
];

function App() {
  …

  return (
    <div className="App">
      <DailyScheduler schedules={initialSchedules} />
      …
    </div>
  );
}

The DailyScheduler must set its schedules to given schedules property. The problem, however, is DailyScheduler can only handle ScheduleItem, which of start and end are number that represents the coordinates in screen, not datetime strings. Before setting schedules, we must convert the array of Schedule into that of ScheduleItem. During conversion, we have to map category_id to schedule category.

Please do the following:

  1. add an useEffect() to DailyScheduler
  2. import schedule categories to daily-scheduler-util
  3. add convertSchedules() to daily-scheduler-util
./daily-scheduler/DailyScheduler.tsx
…
import {
  …
  convertSchedules,
} from "./daily-scheduler-util";
…

export default function DailyScheduler(props: {
  schedules?: Schedule[];
}): JSX.Element {
  …

  useEffect(() => {
    if (props.schedules) {
      setSchedules(convertSchedules(props.schedules));
    }
  }, [props.schedules]);

  …
}
./daily-scheduler/daily-scheduler-util.ts
import categories from "./schedule-categories";

…

export function convertSchedules(ss: Schedule[]): ScheduleItem[] {
  if (ss.length === 0) return [];

  let s: Schedule;
  const sss = new Array(ss.length);

  function findIndexStart(datetime: string) {
    return datetime === s.start;
  }

  function findIndexEnd(datetime: string) {
    return datetime === s.end;
  }

  function findCategory(c: ScheduleCategory) {
    return c.id === s.category_id;
  }

  for (let i = 0; i < ss.length; i++) {
    s = ss[i];
    delete s.user_id;
    const index1 = datetimes.findIndex(findIndexStart);
    const index2 = datetimes.findIndex(findIndexEnd);
    const start = guides[index1];
    const end = guides[index2];
    const category = categories.find(findCategory);
    sss[i] = { ...s, start, end, category };
  }

  return sss;
}

The application now shows the two pre-loaded schedules.

Loading initial schedules

Run

That’s it. Now you can do ADD, LIST, UPDATE, REMOVE, and LOAD schedules, all needed for the complete front-end daily scheduler application.

Here is a screenshot after removing the two initial schedules, then add/update number of schedules:

Complete daily scheduler

And when you click the button, you see data like as follows in the console:

Data of complete daily scheduler

Afterwards

This daily scheduler is based on my real work. I did’t dare to improve the code drastically for the tutorial. If you are a attentive programmer, you recognize a few pitfalls in the scheduler.

Only One Scheduler at a time

We separate UI from the data handler. The data handler cannot be instanced; It exists solely.

If you try to render two of DailyScheduler, the data handler can only handle the second data of scheduler. The data of the first scheduler will be messed up, breaks the application.

To avoid this we can make daily-scheduler-util a class, create a instance for each scheduler.

The other approach I can think of is to use Scaling Up with Reducer and Context method.

Either way, it will be a hard work to upgrade the code.

Schedules beyond range

If the scheduler is passed the schedule data which contains the beyond the range of its datetimes, the scheduler cannot convert Schedule into ScheduleItem, fails to visually render the schedule, hence, breaks the app.

For example, in this tutorial we initialize the scheduler on January 1. The datetime range thus will be "2024-01-01 00:00" to "2024-01-02 02:00". If any of initial schedule data is outside this range, say the schedule appointed "2023-12-31 21:00" to "2024-01-01 06:00" like new year party, the scheduler cannot figure out where to coordinate "2023-12-31 21:00" in screen.

The application is vulnerable to the change on hour1(starting hour of schedule) under operating.