Skip to content

Commit

Permalink
feat: implement undo history
Browse files Browse the repository at this point in the history
  • Loading branch information
zsh-eng committed Apr 22, 2024
1 parent 3865cbe commit 6665aa6
Show file tree
Hide file tree
Showing 12 changed files with 458 additions and 68 deletions.
23 changes: 13 additions & 10 deletions src/components/client-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,24 @@
import { NavigationBar } from "@/components/nav-bar";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { HistoryProvider } from "@/history";
import { trpc } from "@/utils/trpc";
import { PropsWithChildren } from "react";

function ClientLayout({ children }: PropsWithChildren<{}>) {
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NavigationBar />
{children}
<Toaster position="top-center" />
</ThemeProvider>
<HistoryProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NavigationBar />
{children}
<Toaster position="top-center" />
</ThemeProvider>
</HistoryProvider>
);
}

Expand Down
5 changes: 4 additions & 1 deletion src/components/flashcard/flashcard-box.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import Flashcard from "@/components/flashcard/flashcard";
import { useHistory } from "@/history";
import { useGradeCard } from "@/hooks/card/use-grade-card";
import { type Rating } from "@/schema";
import { getReviewDateForEachRating } from "@/utils/fsrs";
Expand Down Expand Up @@ -31,6 +32,7 @@ export default function FlashcardBox({}: Props) {
} = trpc.card.sessionData.useQuery();

const gradeMutation = useGradeCard();
const history = useHistory();
const [isNextCardNew, setIsNextCardNew] = useState(getNew());

if (isLoading) {
Expand Down Expand Up @@ -83,10 +85,11 @@ export default function FlashcardBox({}: Props) {
id: card.cards.id,
});

const id = history.add("grade", card);
toast(`Card marked as ${rating}.`, {
action: {
label: "Undo",
onClick: () => {},
onClick: () => history.undo(id),
},
description: `You'll see this again ${intlFormatDistance(
reviewDay,
Expand Down
53 changes: 44 additions & 9 deletions src/components/flashcard/flashcard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CardContentFormValues, cardContentFormSchema } from "@/form";
import { useHistory } from "@/history";
import { useDeleteCard } from "@/hooks/card/use-delete-card";
import { useEditCard } from "@/hooks/card/use-edit-card";
import { useSuspendCard } from "@/hooks/card/use-suspend.card";
Expand Down Expand Up @@ -83,16 +84,14 @@ export default function Flashcard({
},
});

const editCardMutation = useEditCard();
const deleteCard = useDeleteCard();
const suspendCardMutation = useSuspendCard();
const history = useHistory();

const editCardMutation = useEditCard();
const handleEdit = () => {
// `getValues()` will be undefined if the form is disabled
// TODO use readonly field instead
// See https://www.react-hook-form.com/api/useform/getvalues/#:~:text=%5B%27%27%5D-,Rules,-Disabled%20inputs%20will
const content = form.getValues();

const isQuestionAnswerSame =
content.question === initialCardContent.question &&
content.answer === initialCardContent.answer;
Expand All @@ -103,17 +102,53 @@ export default function Flashcard({
question: content.question,
answer: content.answer,
});

const id = history.add("edit", sessionCard);
toast.success("Card updated.", {
action: {
label: "Undo",
onClick: () => {
history.undo(id);
},
},
});
};

const suspendCardMutation = useSuspendCard();
const handleSkip = () => {
const id = sessionCard.cards.id;
const cardId = sessionCard.cards.id;
const tenMinutesLater = new Date(Date.now() + 1000 * 60 * 10);
suspendCardMutation.mutate({
id,
id: cardId,
suspendUntil: tenMinutesLater,
});
// TODO implement undo functionality
toast.success("Card suspended for 10 minutes.");

const id = history.add("suspend", sessionCard);
toast.success("Card suspended for 10 minutes.", {
action: {
label: "Undo",
onClick: () => {
history.undo(id);
},
},
});
};

const deleteCardMutation = useDeleteCard();
const handleDelete = () => {
deleteCardMutation.mutate({
id: sessionCard.cards.id,
});
const id = history.add("delete", sessionCard);
toast.success("Card deleted.", {
action: {
label: "Undo",
onClick: () => {
console.log(history.entries);
history.undo(id);
},
},
});
};

useKeydownRating(onRating, open, () => setOpen(true));
Expand Down Expand Up @@ -204,7 +239,7 @@ export default function Flashcard({
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => deleteCard.mutate(sessionCard.cards.id)}
onClick={() => handleDelete()}
>
Continue
</AlertDialogAction>
Expand Down
15 changes: 15 additions & 0 deletions src/form.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { states } from "@/schema";
import { z } from "zod";

// Form schemas are shared between the client and the server.
Expand Down Expand Up @@ -80,3 +81,17 @@ export const deckDefaultValues = {
name: "",
description: "",
} satisfies DeckFormValues;

// Manual grading
export const cardSchema = z.object({
id: z.string(),
due: z.date(),
stability: z.number(),
difficulty: z.number(),
elapsed_days: z.number(),
scheduled_days: z.number(),
reps: z.number(),
lapses: z.number(),
state: z.enum(states),
last_review: z.date().nullable(),
});
198 changes: 198 additions & 0 deletions src/history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useDeleteCard } from "@/hooks/card/use-delete-card";
import { useEditCard } from "@/hooks/card/use-edit-card";
import { useManualGradeCard } from "@/hooks/card/use-manual-grade-card";
import { useSuspendCard } from "@/hooks/card/use-suspend.card";
import { SessionCard } from "@/utils/session";
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { toast } from "sonner";

// HistoryContext
// useHistory hook that throws an error if used outside of a HistoryProvider
// HistoryProvider that provides the history state and implements the undo functionality

// The undo functionality should trigger a promise based toast
// If the promise fails, then the toast will display an error message and
// we won't update the history state
// Else, we'll run setState with the filtered entries

type ChangeType = "grade" | "edit" | "delete" | "create" | "suspend";

type HistoryStateEntry = {
id: string;
date: Date;
type: ChangeType;
card: SessionCard;
};

type HistoryState = {
readonly entries: ReadonlyArray<HistoryStateEntry>;
add: (type: ChangeType, previousCard: SessionCard) => string;
undo: (id?: string) => void;
};

const HistoryContext = createContext<HistoryState | undefined>(undefined);

export function useHistory() {
const context = useContext(HistoryContext);
if (!context) {
throw new Error("useHistory must be used within a HistoryProvider");
}
return context;
}

export function HistoryProvider({ children }: PropsWithChildren<{}>) {
const entriesRef = useRef<HistoryStateEntry[]>([]);
const [isUndoing, setIsUndoing] = useState(false);

const editCardMutation = useEditCard();
const deleteCardMutation = useDeleteCard();
const manualGradeCardMutation = useManualGradeCard();
const suspendCardMutation = useSuspendCard();

const add = (type: ChangeType, card: SessionCard): string => {
const id = crypto.randomUUID();
console.log(
"Adding entry",
id,
type,
card.cards.id,
entriesRef.current.length,
);
entriesRef.current.push({
id,
date: new Date(),
type,
card,
});
console.log(entriesRef.current);
return id;
};

const getEntry = (id?: string): HistoryStateEntry | undefined => {
if (!id) {
return entriesRef.current[entriesRef.current.length - 1];
}

const index = entriesRef.current.findIndex((entry) => entry.id === id);
if (index === -1) {
return;
}

return entriesRef.current[index];
};

// We only implement the undo operations here
// Optimistic updates and managing of session data is handled
// by the individual hooks
// TODO check if mutate will throw an error on Error
const undoCreate = async (entry: HistoryStateEntry) => {
await deleteCardMutation.mutateAsync({
id: entry.card.cards.id,
deleted: true,
});
};
const undoGrade = async (entry: HistoryStateEntry) => {
await manualGradeCardMutation.mutateAsync({
card: entry.card.cards,
});
};
const undoDelete = async (entry: HistoryStateEntry) => {
await deleteCardMutation.mutateAsync({
id: entry.card.cards.id,
deleted: false,
});
};
const undoEdit = async (entry: HistoryStateEntry) => {
await editCardMutation.mutateAsync({
cardContentId: entry.card.card_contents.id,
question: entry.card.card_contents.question,
answer: entry.card.card_contents.answer,
});
};
const undoSuspend = async (entry: HistoryStateEntry) => {
await suspendCardMutation.mutateAsync({
id: entry.card.cards.id,
suspendUntil: new Date(entry.card.cards.suspended),
});
};

const undo = (id?: string) => {
console.log("Undoing", entriesRef.current);
if (entriesRef.current.length === 0) {
toast.info("Nothing left to undo.");
return;
}

if (isUndoing) {
return;
}

const entry = getEntry(id);
if (!entry) {
console.log("Entry not found", entriesRef.current);
return;
}

setIsUndoing(true);
switch (entry.type) {
case "create":
toast.promise(undoCreate(entry), {
loading: "Undoing...",
success: "Card deleted.",
error: "Failed to undo card creation. Please try again.",
});
break;
case "grade":
toast.promise(undoGrade(entry), {
loading: "Undoing...",
success: "Rating undone successfully.",
error: "Failed to undo card rating. Please try again.",
});
break;
case "edit":
toast.promise(undoEdit(entry), {
loading: "Undoing...",
success: "Card contents reverted to previous state.",
error: "Failed to undo card edit. Please try again.",
});
break;
case "delete":
toast.promise(undoDelete(entry), {
loading: "Undoing...",
success: "Card restored.",
error: "Failed to undo card deletion. Please try again.",
});
break;
case "suspend":
toast.promise(undoSuspend(entry), {
loading: "Undoing...",
success: "Card unsuspended.",
error: "Failed to undo card suspension. Please try again.",
});
break;
default:
const _: never = entry.type;
}

entriesRef.current = entriesRef.current.filter((e) => e.id !== entry.id);
setIsUndoing(false);
};

const state = {
entries: entriesRef.current,
add,
undo,
isUndoing,
};

return (
<HistoryContext.Provider value={state}>{children}</HistoryContext.Provider>
);
}
Loading

0 comments on commit 6665aa6

Please sign in to comment.