TinyBase logoTinyBase β

Todo App v5 (checkpoints)

In this version of the Todo app, we add a Checkpoints object that provides us with an undo and redo stack as the main store changes.

We're making changes to the Todo App v4 (metrics) demo.

Additional Initialization

We will create a Checkpoints object with the useCreateCheckpoints hook, and will need the useCheckpoints hook to use it throughout the application. The useSetCheckpointCallback hook provides a callback to set a new checkpoint whenever something changes in the application that we would like to undo.

The useUndo hook and useRedo hook provide convenient ways to check whether an undo action or a redo action is available:

-const {createIndexes, createMetrics, createStore} = TinyBase;
+const {createCheckpoints, createIndexes, createMetrics, createStore} = TinyBase;
 const {createLocalPersister, createSessionPersister} = TinyBasePersisterBrowser;
 const {
   CellView,
+  CheckpointView,
   Provider,
   SliceView,
   useAddRowCallback,
   useCell,
+  useCheckpoints,
+  useCreateCheckpoints,
   useCreateIndexes,
   useCreateMetrics,
   useCreatePersister,
   useCreateStore,
   useMetric,
+  useRedoInformation,
+  useRow,
   useSetCellCallback,
+  useSetCheckpointCallback,
   useSetValueCallback,
+  useUndoInformation,
   useValue,
 } = TinyBaseUiReact;

As before, we make this Checkpoints object available though the Provider component as the default for the app. We choose to also clear the Checkpoints object once the persister has completed its initial load so that there isn't the option to 'undo' that first load:

+  const checkpoints = useCreateCheckpoints(store, createCheckpoints);
   useCreatePersister(
     store,
     (store) => createLocalPersister(store, 'todos/store'),
     [],
     async (persister) => {
       await persister.startAutoLoad([INITIAL_TODOS]);
+      checkpoints?.clear();
       await persister.startAutoSave();
     },
+    [checkpoints],
   );

And as before, we make this Checkpoints object available though the Provider component as the default for the app:

   return (
     <Provider
       store={store}
       storesById={{viewStore}}
       indexes={indexes}
       metrics={metrics}
+      checkpoints={checkpoints}
     >
       <Title />
       <NewTodo />
       <Types />
+      <UndoRedo />
       <Todos />
       <Inspector />
     </Provider>
   );
 };

Upgrading useNewTodoCallback

We need to set checkpoints every time something happens in the app that the user might want to undo. One of the actions we want to checkpoint is when a user creates a new todo:

 const NewTodo = () => {
   const [text, setText] = useState('');
   const type = useValue('type', 'viewStore');
   const handleChange = useCallback(({target: {value}}) => setText(value), []);
+  const addCheckpoint = useSetCheckpointCallback(
+    () => `adding '${text}'`,
+    [text],
+  );
   const handleKeyDown = useAddRowCallback(
     'todos',
     ({which, target: {value: text}}) =>
       which == 13 && text != '' ? {text, type} : null,
     [type],
     undefined,
-    () => setText(''),
+    () => {
+      setText('');
+      addCheckpoint();
+    },
-    [setText],
+    [setText, addCheckpoint],
   );

Upgrading TodoType

When a type is changed for a todo, we also create a checkpoint:

 const TodoType = ({tableId, rowId}) => {
   const type = useCell(tableId, rowId, 'type');
+  const checkpoints = useCheckpoints();
   const handleChange = useSetCellCallback(
     tableId,
     rowId,
     'type',
     ({target: {value}}) => value,
     [],
+    undefined,
+    (_store, type) => checkpoints.addCheckpoint(`changing to '${type}'`),
+    [checkpoints],
   );

The sixth parameter is left undefined so that the default store is used. Following that, we are using the 'then' callback that fires after the Cell has been set. It depends on checkpoints, so the final array parameter ensures it is memoized correctly.

Upgrading TodoText

And finally, we create a checkpoint whenever a todo is marked as completed or is being resumed. Our handleClick function now calls both the previous setCell function and a new addCheckpoint function, both from hooks that gave us the default Store and Checkpoint objects:

 const TodoText = ({tableId, rowId}) => {
-  const done = useCell(tableId, rowId, 'done');
+  const {done, text} = useRow(tableId, rowId);
   const className = 'text' + (done ? ' done' : '');
-  const handleClick = useSetCellCallback(tableId, rowId, 'done', () => !done, [
-    done,
-  ]);
+  const setCell = useSetCellCallback(tableId, rowId, 'done', () => !done, [
+    done,
+  ]);
+  const addCheckpoint = useSetCheckpointCallback(
+    () => `${done ? 'resuming' : 'completing'} '${text}'`,
+    [done],
+  );
+  const handleClick = useCallback(() => {
+    setCell();
+    addCheckpoint();
+  }, [setCell, addCheckpoint]);

   return (
     <span className={className} onClick={handleClick}>
       <CellView tableId={tableId} rowId={rowId} cellId="text" />
     </span>
   );
 };

The UndoRedo Components

To provide a way to undo the checkpoints, we create two small affordances on the left of the application. Each is only enabled when there is at least one item to undo or redo:

const UndoRedo = () => {
  const [canUndo, handleUndo, , undoLabel] = useUndoInformation();
  const undo = canUndo ? (
    <div id="undo" onClick={handleUndo}>
      undo {undoLabel}
    </div>
  ) : (
    <div id="undo" className="disabled" />
  );

  const [canRedo, handleRedo, , redoLabel] = useRedoInformation();
  const redo = canRedo ? (
    <div id="redo" onClick={handleRedo}>
      redo {redoLabel}
    </div>
  ) : (
    <div id="redo" className="disabled" />
  );

  return (
    <div id="undoRedo">
      {undo}
      {redo}
    </div>
  );
};

We extend the grid slightly so these components are placed below the type lists, and add some styling:

 body {
   display: grid;
   grid-template-columns: 35% minmax(0, 1fr);
-  grid-template-rows: auto 1fr;
+  grid-template-rows: auto auto 1fr;
#undoRedo {
  grid-column: 1;
  grid-row: 3;
  #undo,
  #redo {
    cursor: pointer;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    user-select: none;
    &::before {
      padding-right: 0.5rem;
      vertical-align: middle;
    }
    &.disabled {
      cursor: default;
      opacity: 0.3;
    }
  }
  #undo::before {
    content: '\21A9';
  }
  #redo::before {
    content: '\21AA';
  }
}

Since we added an extra row to the grid we also make the right hand list of todos span two:

 #todos {
   grid-column: 2;
+  grid-row: 2 / span 2;
   margin: 0;
   padding: 0;
 }

Summary

It's been very straightforward to add a comprehensive undo and redo stack to our app. We could now declare it complete! But wouldn't is also be cool to make it collaborative? If you agree, let's move on to the Todo App v6 (collaboration) demo...