diff --git a/src/api/__tests__/lists.spec.ts b/src/api/__tests__/lists.spec.ts index 7dc4f50..0978488 100644 --- a/src/api/__tests__/lists.spec.ts +++ b/src/api/__tests__/lists.spec.ts @@ -9,6 +9,8 @@ import { setListItemCompletedApi, addUserToListApi, removeUserFromListApi, + deleteListApi, + deleteListItemApi, } from '../lists' import { useAuthStore } from '@/stores/auth' import { API_BASE_URL } from '@/api/auth' @@ -179,4 +181,52 @@ describe('lists API', () => { expect.objectContaining({ method: 'DELETE' }), ) }) + + it('deleteListApi sends DELETE to /lists/{id}', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 204, + } as unknown as Response) + global.fetch = fetchMock + + await deleteListApi('list-1') + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/lists/list-1`, + expect.objectContaining({ method: 'DELETE' }), + ) + }) + + it('deleteListApi throws on failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'List not found' }), + } as unknown as Response) + + await expect(deleteListApi('list-1')).rejects.toThrow('List not found') + }) + + it('deleteListItemApi sends DELETE to /lists/item/{id}', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 204, + } as unknown as Response) + global.fetch = fetchMock + + await deleteListItemApi('item-1') + + expect(fetchMock).toHaveBeenCalledWith( + `${API_BASE_URL}/lists/item/item-1`, + expect.objectContaining({ method: 'DELETE' }), + ) + }) + + it('deleteListItemApi throws on failure', async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: false, + json: async () => ({ message: 'Item not found' }), + } as unknown as Response) + + await expect(deleteListItemApi('item-1')).rejects.toThrow('Item not found') + }) }) diff --git a/src/api/lists.ts b/src/api/lists.ts index fb2730b..740f82c 100644 --- a/src/api/lists.ts +++ b/src/api/lists.ts @@ -83,3 +83,17 @@ export async function removeUserFromListApi(payload: RemoveUserFromListPayload): throw new Error(await extractErrorMessage(response, 'Failed to remove user from list')) } } + +export async function deleteListApi(listId: string): Promise { + const response = await apiClient.delete(`/lists/${listId}`) + if (!response.ok) { + throw new Error(await extractErrorMessage(response, 'Failed to delete list')) + } +} + +export async function deleteListItemApi(itemId: string): Promise { + const response = await apiClient.delete(`/lists/item/${itemId}`) + if (!response.ok) { + throw new Error(await extractErrorMessage(response, 'Failed to delete list item')) + } +} diff --git a/src/components/DeleteListModal.vue b/src/components/DeleteListModal.vue new file mode 100644 index 0000000..1be0dac --- /dev/null +++ b/src/components/DeleteListModal.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/src/components/ListCard.vue b/src/components/ListCard.vue index 8f66c24..05f7703 100644 --- a/src/components/ListCard.vue +++ b/src/components/ListCard.vue @@ -7,6 +7,7 @@ import { useListsStore } from '@/stores/lists' const props = defineProps<{ list: LocalList }>() const emit = defineEmits<{ (e: 'share', list: LocalList): void + (e: 'delete', list: LocalList): void }>() const listsStore = useListsStore() @@ -36,6 +37,13 @@ function handleShare(event: Event) { emit('share', props.list) } +function handleDelete(event: Event) { + event.preventDefault() + event.stopPropagation() + isMenuOpen.value = false + emit('delete', props.list) +} + function handleClickOutside(event: MouseEvent) { if (menuContainerRef.value && !menuContainerRef.value.contains(event.target as Node)) { isMenuOpen.value = false @@ -108,6 +116,28 @@ onUnmounted(() => { Share list + @@ -250,6 +280,15 @@ onUnmounted(() => { color: var(--c-accent-strong); } +.submenu-item-danger { + color: var(--c-danger); +} + +.submenu-item-danger:hover { + background-color: var(--c-danger-bg); + color: var(--c-danger); +} + .submenu-icon { width: 15px; height: 15px; diff --git a/src/components/ListItemRow.vue b/src/components/ListItemRow.vue index 962c654..1422149 100644 --- a/src/components/ListItemRow.vue +++ b/src/components/ListItemRow.vue @@ -1,13 +1,18 @@ @@ -116,4 +200,103 @@ function saveTitle() { background-color: var(--c-warning); flex-shrink: 0; } + +.menu-container { + position: relative; + display: flex; + align-items: center; + flex-shrink: 0; +} + +.menu-trigger-btn { + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--c-text-soft); + cursor: pointer; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: + background-color 0.15s ease-in-out, + color 0.15s ease-in-out, + border-color 0.15s ease-in-out; +} + +.menu-trigger-btn:hover, +.menu-trigger-btn[aria-expanded='true'] { + background-color: var(--c-bg-mute); + color: var(--c-heading); + border-color: var(--c-border); +} + +.dots-icon { + display: block; +} + +.submenu-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + min-width: 140px; + background-color: var(--c-bg-elevated); + border: 1px solid var(--c-border-hover); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + padding: 0.35rem; + animation: dropdownIn 0.12s ease-out; +} + +.submenu-item { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.5rem 0.65rem; + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--c-heading); + font-size: 0.85rem; + cursor: pointer; + text-align: left; + transition: + background-color 0.15s ease-in-out, + color 0.15s ease-in-out; +} + +.submenu-item:hover { + background-color: var(--c-bg-mute); + color: var(--c-accent-strong); +} + +.submenu-item-danger { + color: var(--c-danger); +} + +.submenu-item-danger:hover { + background-color: var(--c-danger-bg); + color: var(--c-danger); +} + +.submenu-icon { + width: 15px; + height: 15px; + flex-shrink: 0; +} + +@keyframes dropdownIn { + from { + opacity: 0; + transform: translateY(-4px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/src/components/ShareListModal.vue b/src/components/ShareListModal.vue new file mode 100644 index 0000000..bdce222 --- /dev/null +++ b/src/components/ShareListModal.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/src/components/__tests__/DeleteListModal.spec.ts b/src/components/__tests__/DeleteListModal.spec.ts new file mode 100644 index 0000000..421e533 --- /dev/null +++ b/src/components/__tests__/DeleteListModal.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import DeleteListModal from '../DeleteListModal.vue' +import type { LocalList } from '@/database/db' + +describe('DeleteListModal', () => { + const sampleList: LocalList = { + id: 'list-123', + name: 'Groceries', + created_at: '2026-08-21T00:00:00.000Z', + modified_at: '2026-08-21T00:00:00.000Z', + } + + it('renders modal with list name and confirmation prompt', () => { + const wrapper = mount(DeleteListModal, { + props: { + list: sampleList, + }, + }) + + expect(wrapper.text()).toContain('Delete "Groceries"?') + expect(wrapper.text()).toContain('Are you sure you want to delete this list?') + expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete list') + expect(wrapper.find('.cancel-btn').text()).toBe('Cancel') + }) + + it('emits confirm event when Delete button is clicked', async () => { + const wrapper = mount(DeleteListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('.confirm-delete-btn').trigger('click') + expect(wrapper.emitted('confirm')).toBeTruthy() + }) + + it('emits close event when Cancel button is clicked', async () => { + const wrapper = mount(DeleteListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('.cancel-btn').trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('emits close event when close icon button is clicked', async () => { + const wrapper = mount(DeleteListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('.close-btn').trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('emits close event when clicking overlay background', async () => { + const wrapper = mount(DeleteListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('.modal-overlay').trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + }) +}) diff --git a/src/components/__tests__/ListCard.spec.ts b/src/components/__tests__/ListCard.spec.ts new file mode 100644 index 0000000..3b608cc --- /dev/null +++ b/src/components/__tests__/ListCard.spec.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ListCard from '../ListCard.vue' +import type { LocalList } from '@/database/db' + +describe('ListCard', () => { + const sampleList: LocalList = { + id: 'list-123', + name: 'Groceries', + created_at: '2026-08-21T00:00:00.000Z', + modified_at: '2026-08-21T00:00:00.000Z', + } + + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('renders list name and options button', () => { + const wrapper = mount(ListCard, { + props: { + list: sampleList, + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + expect(wrapper.text()).toContain('Groceries') + expect(wrapper.find('.menu-trigger-btn').exists()).toBe(true) + expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) + }) + + it('toggles dropdown submenu when options button is clicked', async () => { + const wrapper = mount(ListCard, { + props: { + list: sampleList, + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + await wrapper.find('.menu-trigger-btn').trigger('click') + expect(wrapper.find('.submenu-dropdown').exists()).toBe(true) + expect(wrapper.find('.submenu-item').text()).toContain('Share list') + + await wrapper.find('.menu-trigger-btn').trigger('click') + expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) + }) + + it('emits share event when Share list is clicked in submenu', async () => { + const wrapper = mount(ListCard, { + props: { + list: sampleList, + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + await wrapper.find('.menu-trigger-btn').trigger('click') + await wrapper.find('.submenu-item').trigger('click') + + expect(wrapper.emitted('share')).toBeTruthy() + expect(wrapper.emitted('share')?.[0]).toEqual([sampleList]) + expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) + }) + + it('emits delete event when Delete list is clicked in submenu', async () => { + const wrapper = mount(ListCard, { + props: { + list: sampleList, + }, + global: { + stubs: { + RouterLink: { + template: '', + props: ['to'], + }, + }, + }, + }) + + await wrapper.find('.menu-trigger-btn').trigger('click') + const deleteBtn = wrapper.find('.submenu-item-danger') + expect(deleteBtn.exists()).toBe(true) + expect(deleteBtn.text()).toContain('Delete list') + + await deleteBtn.trigger('click') + + expect(wrapper.emitted('delete')).toBeTruthy() + expect(wrapper.emitted('delete')?.[0]).toEqual([sampleList]) + expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) + }) +}) diff --git a/src/components/__tests__/ListItemRow.spec.ts b/src/components/__tests__/ListItemRow.spec.ts new file mode 100644 index 0000000..9f469ca --- /dev/null +++ b/src/components/__tests__/ListItemRow.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ListItemRow from '../ListItemRow.vue' +import { useListsStore } from '@/stores/lists' +import type { LocalListItem } from '@/database/db' + +describe('ListItemRow', () => { + const sampleItem: LocalListItem = { + id: 'item-1', + list_id: 'list-1', + title: 'Apples', + is_completed: false, + created_at: '2026-08-21T00:00:00.000Z', + modified_at: '2026-08-21T00:00:00.000Z', + } + + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('renders item title and options menu button', () => { + const wrapper = mount(ListItemRow, { + props: { + item: sampleItem, + }, + }) + + expect(wrapper.text()).toContain('Apples') + expect(wrapper.find('.menu-trigger-btn').exists()).toBe(true) + expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) + }) + + it('toggles dropdown when menu button is clicked', async () => { + const wrapper = mount(ListItemRow, { + props: { + item: sampleItem, + }, + }) + + await wrapper.find('.menu-trigger-btn').trigger('click') + expect(wrapper.find('.submenu-dropdown').exists()).toBe(true) + expect(wrapper.find('.submenu-item-danger').text()).toContain('Delete item') + + await wrapper.find('.menu-trigger-btn').trigger('click') + expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) + }) + + it('deletes item when Delete item is clicked in submenu', async () => { + const wrapper = mount(ListItemRow, { + props: { + item: sampleItem, + }, + }) + + const listsStore = useListsStore() + const deleteSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue() + + // 1st click: open menu + await wrapper.find('.menu-trigger-btn').trigger('click') + expect(wrapper.find('.submenu-dropdown').exists()).toBe(true) + + // 2nd click: delete item + await wrapper.find('.submenu-item-danger').trigger('click') + + expect(deleteSpy).toHaveBeenCalledWith('item-1') + expect(wrapper.emitted('delete')).toBeTruthy() + expect(wrapper.emitted('delete')?.[0]).toEqual([sampleItem]) + expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) + }) +}) diff --git a/src/components/__tests__/ShareListModal.spec.ts b/src/components/__tests__/ShareListModal.spec.ts new file mode 100644 index 0000000..26a535c --- /dev/null +++ b/src/components/__tests__/ShareListModal.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import ShareListModal from '../ShareListModal.vue' +import { useListsStore } from '@/stores/lists' +import type { LocalList } from '@/database/db' + +describe('ShareListModal', () => { + const sampleList: LocalList = { + id: 'list-123', + name: 'Groceries', + created_at: '2026-08-21T00:00:00.000Z', + modified_at: '2026-08-21T00:00:00.000Z', + } + + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('renders modal with list name and user input', () => { + const wrapper = mount(ShareListModal, { + props: { + list: sampleList, + }, + }) + + expect(wrapper.text()).toContain('Share "Groceries"') + expect(wrapper.find('input[placeholder="Email"]').exists()).toBe(true) + expect(wrapper.find('button[type="submit"]').text()).toBe('Add') + }) + + it('submits form to share list with an email', async () => { + const listsStore = useListsStore() + const addSpy = vi.spyOn(listsStore, 'addUserToList').mockResolvedValueOnce() + + const wrapper = mount(ShareListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('input').setValue('user@example.com') + await wrapper.find('form').trigger('submit.prevent') + + expect(addSpy).toHaveBeenCalledWith('list-123', 'user@example.com') + expect(wrapper.find('.banner-success').text()).toContain('Shared with "user@example.com"!') + }) + + it('displays error banner when sharing fails', async () => { + const listsStore = useListsStore() + vi.spyOn(listsStore, 'addUserToList').mockRejectedValueOnce(new Error('User not found')) + + const wrapper = mount(ShareListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('input').setValue('bad@example.com') + await wrapper.find('form').trigger('submit.prevent') + + expect(wrapper.find('.banner-error').text()).toContain('User not found') + }) + + it('emits close event when close button is clicked', async () => { + const wrapper = mount(ShareListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('.close-btn').trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + }) + + it('emits close event when Done button is clicked', async () => { + const wrapper = mount(ShareListModal, { + props: { + list: sampleList, + }, + }) + + await wrapper.find('.modal-footer .btn').trigger('click') + expect(wrapper.emitted('close')).toBeTruthy() + }) +}) diff --git a/src/database/db.ts b/src/database/db.ts index 4125314..abe2f40 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -16,6 +16,8 @@ export type SyncOperationType = | 'setListItemCompleted' | 'addUserToList' | 'removeUserFromList' + | 'deleteList' + | 'deleteListItem' export interface SyncQueueEntry { id?: number diff --git a/src/stores/__tests__/lists.spec.ts b/src/stores/__tests__/lists.spec.ts index b28ad5f..117652d 100644 --- a/src/stores/__tests__/lists.spec.ts +++ b/src/stores/__tests__/lists.spec.ts @@ -92,6 +92,8 @@ const listsApiMocks = vi.hoisted(() => ({ setListItemCompletedApi: vi.fn<() => Promise>(), addUserToListApi: vi.fn<() => Promise>(), removeUserFromListApi: vi.fn<() => Promise>(), + deleteListApi: vi.fn<() => Promise>(), + deleteListItemApi: vi.fn<() => Promise>(), })) vi.mock('@/api/lists', () => listsApiMocks) @@ -276,4 +278,45 @@ describe('useListsStore', () => { expect(stillLocal?.name).toBe('Local only') expect(stillLocal?.pendingSync).toBe(true) }) + + it('deletes a list locally and syncs deletion to the server', async () => { + listsApiMocks.deleteListApi.mockResolvedValueOnce(undefined) + + const store = useListsStore() + await fakeDb.lists.put({ id: 'list-to-delete', name: 'Delete Me' }) + await fakeDb.listItems.put({ id: 'item-in-list', list_id: 'list-to-delete', title: 'Item' }) + await store.refresh() + + expect(store.lists.find((l) => l.id === 'list-to-delete')).toBeDefined() + expect(store.listItems.find((i) => i.id === 'item-in-list')).toBeDefined() + + await store.deleteList('list-to-delete') + + expect(store.lists.find((l) => l.id === 'list-to-delete')).toBeUndefined() + expect(store.listItems.find((i) => i.id === 'item-in-list')).toBeUndefined() + + await store.sync() + + expect(listsApiMocks.deleteListApi).toHaveBeenCalledWith('list-to-delete') + expect(store.pendingCount).toBe(0) + }) + + it('deletes a list item locally and syncs deletion to the server', async () => { + listsApiMocks.deleteListItemApi.mockResolvedValueOnce(undefined) + + const store = useListsStore() + await fakeDb.listItems.put({ id: 'item-to-delete', list_id: 'list-1', title: 'Delete Me' }) + await store.refresh() + + expect(store.listItems.find((i) => i.id === 'item-to-delete')).toBeDefined() + + await store.deleteListItem('item-to-delete') + + expect(store.listItems.find((i) => i.id === 'item-to-delete')).toBeUndefined() + + await store.sync() + + expect(listsApiMocks.deleteListItemApi).toHaveBeenCalledWith('item-to-delete') + expect(store.pendingCount).toBe(0) + }) }) diff --git a/src/stores/lists.ts b/src/stores/lists.ts index 3dd1c56..2327761 100644 --- a/src/stores/lists.ts +++ b/src/stores/lists.ts @@ -10,6 +10,8 @@ import { setListItemCompletedApi, addUserToListApi, removeUserFromListApi, + deleteListApi, + deleteListItemApi, } from '@/api/lists' function generateId(): string { @@ -162,6 +164,32 @@ export const useListsStore = defineStore('lists', () => { void sync() } + async function deleteList(listId: string) { + await db.lists.delete(listId) + const affectedItems = await db.listItems.where('list_id').equals(listId).toArray() + for (const item of affectedItems) { + await db.listItems.delete(item.id) + } + await enqueue({ + type: 'deleteList', + payload: { id: listId }, + localListId: listId, + }) + await refresh() + void sync() + } + + async function deleteListItem(itemId: string) { + await db.listItems.delete(itemId) + await enqueue({ + type: 'deleteListItem', + payload: { id: itemId }, + localListItemId: itemId, + }) + await refresh() + void sync() + } + // Remaps a client-generated temporary list id to the id assigned by the // server once the "createList" sync operation succeeds. This keeps any // items or queued operations referencing the temporary id consistent. @@ -183,10 +211,14 @@ export const useListsStore = defineStore('lists', () => { .filter((entry) => entry.localListId === oldId) .toArray() for (const entry of affectedQueueEntries) { - const payload = entry.payload as { list_id?: string } + const payload = entry.payload as { list_id?: string; id?: string } await db.syncQueue.update(entry.id!, { localListId: newId, - payload: payload?.list_id ? { ...payload, list_id: newId } : entry.payload, + payload: payload?.list_id + ? { ...payload, list_id: newId } + : payload?.id + ? { ...payload, id: newId } + : entry.payload, }) } } @@ -208,10 +240,14 @@ export const useListsStore = defineStore('lists', () => { .filter((queueEntry) => queueEntry.localListItemId === oldId) .toArray() for (const queueEntry of affectedQueueEntries) { - const payload = queueEntry.payload as { list_item_id?: string } + const payload = queueEntry.payload as { list_item_id?: string; id?: string } await db.syncQueue.update(queueEntry.id!, { localListItemId: newId, - payload: payload?.list_item_id ? { ...payload, list_item_id: newId } : queueEntry.payload, + payload: payload?.list_item_id + ? { ...payload, list_item_id: newId } + : payload?.id + ? { ...payload, id: newId } + : queueEntry.payload, }) } } @@ -262,6 +298,16 @@ export const useListsStore = defineStore('lists', () => { await removeUserFromListApi(entry.payload as { list_id: string; email: string }) break } + case 'deleteList': { + const payload = entry.payload as { id: string } + await deleteListApi(payload.id) + break + } + case 'deleteListItem': { + const payload = entry.payload as { id: string } + await deleteListItemApi(payload.id) + break + } } } @@ -390,6 +436,8 @@ export const useListsStore = defineStore('lists', () => { setListItemCompleted, addUserToList, removeUserFromList, + deleteList, + deleteListItem, sync, pullFromServer, pullListItems, diff --git a/src/views/ListDetailView.vue b/src/views/ListDetailView.vue index 6f6cf5d..3c8838d 100644 --- a/src/views/ListDetailView.vue +++ b/src/views/ListDetailView.vue @@ -1,8 +1,9 @@