Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions e2e/tasks/delete-restore-task.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test } from "@playwright/test";
import {
createTask,
deleteTaskWithKeyboard,
expectTaskMissing,
expectTaskSavedToIndexedDB,
expectTaskVisible,
prepareTaskPage,
restoreDeletedTaskFromUndoToast,
} from "../utils/task-test-utils";

test.describe("Task Delete + Restore", () => {
test.skip(
({ isMobile }) => isMobile,
"Tasks are not available in the current mobile experience.",
);

test("should restore a deleted task from the undo toast", async ({
page,
}) => {
await prepareTaskPage(page);

const taskTitle = `Delete Restore Task ${Date.now()}`;
await createTask(page, taskTitle);

await expectTaskVisible(page, taskTitle);
await expectTaskSavedToIndexedDB(page, taskTitle);

await deleteTaskWithKeyboard(page, taskTitle);
await expectTaskMissing(page, taskTitle);

await restoreDeletedTaskFromUndoToast(page);
await expectTaskVisible(page, taskTitle);
await expectTaskSavedToIndexedDB(page, taskTitle);

await page.reload();
await expectTaskVisible(page, taskTitle, 10000);
await expectTaskSavedToIndexedDB(page, taskTitle);
});
});
36 changes: 34 additions & 2 deletions e2e/utils/task-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,44 @@ export const expectTaskVisible = async (
title: string,
timeout = 10000,
) => {
// Tasks are rendered as inputs
await expect(page.locator(`input[value="${title}"]`)).toBeVisible({
await expect(
page.getByRole("textbox", { name: `Edit ${title}` }),
).toBeVisible({
timeout,
});
};

export const expectTaskMissing = async (
page: Page,
title: string,
timeout = 10000,
) => {
await expect(
page.getByRole("textbox", { name: `Edit ${title}` }),
).toHaveCount(0, {
timeout,
});
};

export const deleteTaskWithKeyboard = async (page: Page, title: string) => {
const taskCheckbox = page.getByRole("checkbox", {
name: `Toggle ${title}`,
});

await expect(taskCheckbox).toBeVisible();
await taskCheckbox.focus();
await page.keyboard.press("Delete");
};

export const restoreDeletedTaskFromUndoToast = async (page: Page) => {
const undoDeleteText = page.getByText("Deleted").first();

await expect(undoDeleteText).toBeVisible();
await undoDeleteText.click();
await page.keyboard.press("Meta+z");
await page.keyboard.press("Control+z");
Comment thread
tyler-dane marked this conversation as resolved.
};

export const expectTaskSavedToIndexedDB = async (page: Page, title: string) => {
let lastSnapshot: unknown = null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,29 @@ import {
Payload_Order,
Schema_Event,
} from "@core/types/event.types";
import { isDateRangeOverlapping } from "@core/util/date/date.util";
import dayjs from "@core/util/date/dayjs";
import {
createTestCompassEvent,
createTestEvent,
} from "@web/__tests__/utils/repositories/repository.test.factory";
import * as storageAdapter from "@web/common/storage/adapter/adapter";
import { LocalEventRepository } from "./local.event.repository";

const mockEvents = new Map<string, Event_Core>();

jest.mock("@web/common/utils/storage/event.storage.util", () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { isDateRangeOverlapping } = require("@core/util/date/date.util");
function createMockAdapter() {
return {
saveEventToIndexedDB: jest.fn(async (event: Event_Core) => {
mockEvents.set(event._id!, event);
}),
loadEventsFromIndexedDB: jest.fn(
initialize: jest.fn().mockResolvedValue(undefined),
isReady: jest.fn().mockReturnValue(true),
getTasks: jest.fn().mockResolvedValue([]),
getAllTasks: jest.fn().mockResolvedValue([]),
putTasks: jest.fn().mockResolvedValue(undefined),
putTask: jest.fn().mockResolvedValue(undefined),
deleteTask: jest.fn().mockResolvedValue(undefined),
moveTask: jest.fn().mockResolvedValue(undefined),
clearAllTasks: jest.fn().mockResolvedValue(undefined),
getEvents: jest.fn(
async (startDate: string, endDate: string, isSomeday?: boolean) => {
const allEvents = Array.from(mockEvents.values());
let filtered = allEvents.filter((event) => {
Expand All @@ -39,31 +45,36 @@ jest.mock("@web/common/utils/storage/event.storage.util", () => {
return filtered;
},
),
deleteEventFromIndexedDB: jest.fn(async (id: string) => {
getAllEvents: jest.fn(async () => Array.from(mockEvents.values())),
putEvent: jest.fn(async (event: Event_Core) => {
mockEvents.set(event._id!, event);
}),
putEvents: jest.fn().mockResolvedValue(undefined),
deleteEvent: jest.fn(async (id: string) => {
mockEvents.delete(id);
}),
clearAllEvents: jest.fn().mockResolvedValue(undefined),
getMigrationRecords: jest.fn().mockResolvedValue([]),
setMigrationRecord: jest.fn().mockResolvedValue(undefined),
};
});
}

jest.mock("@web/common/utils/storage/compass-local.db", () => {
return {
compassLocalDB: {
events: {
toArray: jest.fn(async () => Array.from(mockEvents.values())),
},
},
};
});
jest.mock("@web/common/storage/adapter/adapter");

describe("LocalEventRepository", () => {
let repository: LocalEventRepository;
let mockAdapter: ReturnType<typeof createMockAdapter>;

beforeEach(async () => {
repository = new LocalEventRepository();
mockEvents.clear();
mockAdapter = createMockAdapter();
(storageAdapter.getStorageAdapter as jest.Mock).mockReturnValue(
mockAdapter,
);
repository = new LocalEventRepository();
});

afterEach(async () => {
afterEach(() => {
mockEvents.clear();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ import {
RecurringEventUpdateScope,
Schema_Event,
} from "@core/types/event.types";
import {
deleteEventFromIndexedDB,
loadEventsFromIndexedDB,
saveEventToIndexedDB,
} from "@web/common/utils/storage/event.storage.util";
import { getStorageAdapter } from "@web/common/storage/adapter/adapter";
import { Response_GetEventsSuccess } from "@web/ducks/events/event.types";
import { EventRepository } from "./event.repository.interface";

/**
* Local event repository implementation using the storage adapter.
*
* This repository delegates all storage operations to the StorageAdapter,
* making it independent of the underlying storage technology.
*/
export class LocalEventRepository implements EventRepository {
private get adapter() {
return getStorageAdapter();
}

async create(event: Schema_Event | Schema_Event[]): Promise<void> {
const events = Array.isArray(event) ? event : [event];

Expand All @@ -22,7 +28,7 @@ export class LocalEventRepository implements EventRepository {

for (const e of events) {
try {
await saveEventToIndexedDB(e as Event_Core);
await this.adapter.putEvent(e as Event_Core);
} catch (error) {
errors.push({ event: e, error });
}
Expand All @@ -37,7 +43,7 @@ export class LocalEventRepository implements EventRepository {
}

async get(params: Params_Events): Promise<Response_GetEventsSuccess> {
const events = await loadEventsFromIndexedDB(
const events = await this.adapter.getEvents(
params.startDate,
params.endDate,
params.someday,
Expand All @@ -57,27 +63,25 @@ export class LocalEventRepository implements EventRepository {
async edit(
_id: string,
event: Schema_Event,
params: { applyTo?: RecurringEventUpdateScope },
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_params: { applyTo?: RecurringEventUpdateScope },
): Promise<void> {
// For local repository, we just save the updated event
// The applyTo parameter is not relevant for local storage
await saveEventToIndexedDB(event as Event_Core);
await this.adapter.putEvent(event as Event_Core);
}

async delete(
_id: string,
applyTo?: RecurringEventUpdateScope,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_applyTo?: RecurringEventUpdateScope,
): Promise<void> {
// For local repository, applyTo is not relevant
await deleteEventFromIndexedDB(_id);
await this.adapter.deleteEvent(_id);
}

async reorder(order: Payload_Order[]): Promise<void> {
// Load all events from IndexedDB, update their order, and save back
const { compassLocalDB } = await import(
"@web/common/utils/storage/compass-local.db"
);
const allEvents = await compassLocalDB.events.toArray();
const allEvents = await this.adapter.getAllEvents();
const orderMap = new Map(order.map((o) => [o._id, o.order]));

// Track errors for individual event saves
Expand All @@ -86,9 +90,11 @@ export class LocalEventRepository implements EventRepository {
for (const event of allEvents) {
const eventId = event._id;
if (eventId && orderMap.has(eventId)) {
event.order = orderMap.get(eventId);
// Cast to Schema_Event which includes order property
const eventWithOrder = event as unknown as Schema_Event;
eventWithOrder.order = orderMap.get(eventId);
try {
await saveEventToIndexedDB(event);
await this.adapter.putEvent(event);
} catch (error) {
errors.push({ eventId, error });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class CloudTaskRepository implements TaskRepository {
throw this.createNotImplementedError("get");
}

async save(_dateKey: string, _tasks: Task[]): Promise<void> {
async save(_dateKey: string, _taskOrTasks: Task | Task[]): Promise<void> {
throw this.createNotImplementedError("save");
}

Expand Down
Loading