List improvements #17
@@ -101,7 +101,7 @@ describe('lists API', () => {
|
|||||||
} as unknown as Response)
|
} as unknown as Response)
|
||||||
global.fetch = fetchMock
|
global.fetch = fetchMock
|
||||||
|
|
||||||
const result = await createListApi({ name: 'Groceries', user_ids: [] })
|
const result = await createListApi({ name: 'Groceries' })
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
`${API_BASE_URL}/lists`,
|
`${API_BASE_URL}/lists`,
|
||||||
@@ -119,7 +119,7 @@ describe('lists API', () => {
|
|||||||
await expect(createListApi({ name: 'x' })).rejects.toThrow('Failed')
|
await expect(createListApi({ name: 'x' })).rejects.toThrow('Failed')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('createListItemApi sends POST to /lists/item and returns the created item with its server id', async () => {
|
it('createListItemApi sends POST to /lists/items and returns the created item with its server id', async () => {
|
||||||
const mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false }
|
const mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false }
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -131,13 +131,13 @@ describe('lists API', () => {
|
|||||||
const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' })
|
const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' })
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
`${API_BASE_URL}/lists/item`,
|
`${API_BASE_URL}/lists/items`,
|
||||||
expect.objectContaining({ method: 'POST' }),
|
expect.objectContaining({ method: 'POST' }),
|
||||||
)
|
)
|
||||||
expect(result).toEqual(mockItem)
|
expect(result).toEqual(mockItem)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updateListItemApi sends PUT to /lists/item', async () => {
|
it('updateListItemApi sends PUT to /lists/items', async () => {
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 204,
|
status: 204,
|
||||||
@@ -147,7 +147,7 @@ describe('lists API', () => {
|
|||||||
await updateListItemApi({ list_item_id: 'item-1', is_completed: true })
|
await updateListItemApi({ list_item_id: 'item-1', is_completed: true })
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
`${API_BASE_URL}/lists/item`,
|
`${API_BASE_URL}/lists/items`,
|
||||||
expect.objectContaining({ method: 'PUT' }),
|
expect.objectContaining({ method: 'PUT' }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -206,7 +206,7 @@ describe('lists API', () => {
|
|||||||
await expect(deleteListApi('list-1')).rejects.toThrow('List not found')
|
await expect(deleteListApi('list-1')).rejects.toThrow('List not found')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('deleteListItemApi sends DELETE to /lists/item/{id}', async () => {
|
it('deleteListItemApi sends DELETE to /lists/items/{id}', async () => {
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 204,
|
status: 204,
|
||||||
@@ -216,7 +216,7 @@ describe('lists API', () => {
|
|||||||
await deleteListItemApi('item-1')
|
await deleteListItemApi('item-1')
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
`${API_BASE_URL}/lists/item/item-1`,
|
`${API_BASE_URL}/lists/items/item-1`,
|
||||||
expect.objectContaining({ method: 'DELETE' }),
|
expect.objectContaining({ method: 'DELETE' }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-3
@@ -36,7 +36,7 @@ export async function getListItemsApi(listId: string): Promise<ListItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createListItemApi(payload: CreateListItemPayload): Promise<ListItem> {
|
export async function createListItemApi(payload: CreateListItemPayload): Promise<ListItem> {
|
||||||
const response = await apiClient.post('/lists/item', payload)
|
const response = await apiClient.post('/lists/items', payload)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await extractErrorMessage(response, 'Failed to create list item'))
|
throw new Error(await extractErrorMessage(response, 'Failed to create list item'))
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ export async function createListItemApi(payload: CreateListItemPayload): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateListItemApi(payload: UpdateListItemPayload): Promise<void> {
|
export async function updateListItemApi(payload: UpdateListItemPayload): Promise<void> {
|
||||||
const response = await apiClient.put('/lists/item', payload)
|
const response = await apiClient.put('/lists/items', payload)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await extractErrorMessage(response, 'Failed to update list item'))
|
throw new Error(await extractErrorMessage(response, 'Failed to update list item'))
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,7 @@ export async function deleteListApi(listId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteListItemApi(itemId: string): Promise<void> {
|
export async function deleteListItemApi(itemId: string): Promise<void> {
|
||||||
const response = await apiClient.delete(`/lists/item/${itemId}`)
|
const response = await apiClient.delete(`/lists/items/${itemId}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(await extractErrorMessage(response, 'Failed to delete list item'))
|
throw new Error(await extractErrorMessage(response, 'Failed to delete list item'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,11 @@ async function shareInvite(invite: Invite) {
|
|||||||
|
|
||||||
if (typeof navigator.share === 'function') {
|
if (typeof navigator.share === 'function') {
|
||||||
try {
|
try {
|
||||||
await navigator.share({ title: 'Join dttmr', text: 'Use this link to create your account', url })
|
await navigator.share({
|
||||||
|
title: 'Join dttmr',
|
||||||
|
text: 'Use this link to create your account',
|
||||||
|
url,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error && err.name === 'AbortError') {
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
@@ -248,7 +252,15 @@ function inviteDetail(invite: Invite): string {
|
|||||||
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
|
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
|
||||||
|
|
||||||
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
|
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
|
||||||
<button type="button" class="ticket-btn" @click="shareInvite(invite)">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ticket-btn"
|
||||||
|
:disabled="inviteStatus(invite) !== 'active'"
|
||||||
|
:title="
|
||||||
|
inviteStatus(invite) !== 'active' ? 'Only active invites can be shared' : ''
|
||||||
|
"
|
||||||
|
@click="shareInvite(invite)"
|
||||||
|
>
|
||||||
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
|
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -262,6 +262,34 @@ describe('InvitesPanel', () => {
|
|||||||
expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true)
|
expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('disables share for used and expired invites, but leaves expired invites deletable', async () => {
|
||||||
|
const usedInvite: Invite = {
|
||||||
|
id: 'invite-1',
|
||||||
|
code: 'USEDCODE',
|
||||||
|
consumed_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
const expiredInvite: Invite = {
|
||||||
|
id: 'invite-2',
|
||||||
|
code: 'EXPCODE',
|
||||||
|
expires_at: '2020-01-01T00:00:00.000Z',
|
||||||
|
}
|
||||||
|
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(
|
||||||
|
paginated([usedInvite, expiredInvite]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const wrapper = mount(InvitesPanel)
|
||||||
|
await wrapper.find('.invites-toggle').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const shareButtons = wrapper.findAll('.ticket-btn:not(.ticket-btn-danger)')
|
||||||
|
expect((shareButtons[0]!.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect((shareButtons[1]!.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
|
||||||
|
const deleteButtons = wrapper.findAll('.ticket-btn-danger')
|
||||||
|
expect((deleteButtons[0]!.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect((deleteButtons[1]!.element as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('shows an error banner when loading invites fails', async () => {
|
it('shows an error banner when loading invites fails', async () => {
|
||||||
vi.spyOn(invitesApi, 'getInvitesApi').mockRejectedValueOnce(new Error('Network error'))
|
vi.spyOn(invitesApi, 'getInvitesApi').mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
|||||||
@@ -162,14 +162,13 @@ describe('useListsStore', () => {
|
|||||||
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-id-1', name: 'Groceries' }])
|
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-id-1', name: 'Groceries' }])
|
||||||
|
|
||||||
const store = useListsStore()
|
const store = useListsStore()
|
||||||
const localList = await store.createList('Groceries', [])
|
const localList = await store.createList('Groceries')
|
||||||
|
|
||||||
// wait for the fire-and-forget sync triggered by createList to settle
|
// wait for the fire-and-forget sync triggered by createList to settle
|
||||||
await store.sync()
|
await store.sync()
|
||||||
|
|
||||||
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
|
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
|
||||||
name: 'Groceries',
|
name: 'Groceries',
|
||||||
user_ids: [],
|
|
||||||
})
|
})
|
||||||
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
|
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
|
||||||
const synced = store.lists.find((list) => list.id === 'server-id-1')
|
const synced = store.lists.find((list) => list.id === 'server-id-1')
|
||||||
|
|||||||
+2
-2
@@ -134,7 +134,7 @@ export const useListsStore = defineStore('lists', () => {
|
|||||||
}, SYNC_DEBOUNCE_MS)
|
}, SYNC_DEBOUNCE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createList(name: string, userIds: string[] = []): Promise<LocalList> {
|
async function createList(name: string): Promise<LocalList> {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const localList: LocalList = {
|
const localList: LocalList = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -148,7 +148,7 @@ export const useListsStore = defineStore('lists', () => {
|
|||||||
upsertList(localList)
|
upsertList(localList)
|
||||||
await enqueue({
|
await enqueue({
|
||||||
type: 'createList',
|
type: 'createList',
|
||||||
payload: { name, user_ids: userIds },
|
payload: { name },
|
||||||
localListId: localList.id,
|
localListId: localList.id,
|
||||||
})
|
})
|
||||||
scheduleSync()
|
scheduleSync()
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export interface ListItem {
|
|||||||
|
|
||||||
export interface CreateListPayload {
|
export interface CreateListPayload {
|
||||||
name: string
|
name: string
|
||||||
user_ids?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateListItemPayload {
|
export interface CreateListItemPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user