mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: checklists in sidebar
This commit is contained in:
@@ -58,9 +58,15 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
|
||||
}
|
||||
}
|
||||
|
||||
// Each test uses a unique houseId so module-level shared state doesn't leak
|
||||
// between tests. That is also what the production sharing guarantees — same
|
||||
// houseId → same state, different houseId → independent state.
|
||||
let houseCounter = 100
|
||||
|
||||
describe('useChecklists', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
houseCounter++
|
||||
})
|
||||
|
||||
describe('load', () => {
|
||||
@@ -68,7 +74,7 @@ describe('useChecklists', () => {
|
||||
const lists = [makeList({ id: 1 }), makeList({ id: 2 })]
|
||||
mockApi.listLists.mockResolvedValue(lists)
|
||||
|
||||
const c = useChecklists(1)
|
||||
const c = useChecklists(houseCounter)
|
||||
await c.load()
|
||||
|
||||
expect(c.lists.value).toEqual(lists)
|
||||
@@ -79,11 +85,21 @@ describe('useChecklists', () => {
|
||||
it('sets error on failure', async () => {
|
||||
mockApi.listLists.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const c = useChecklists(1)
|
||||
const c = useChecklists(houseCounter)
|
||||
await c.load()
|
||||
|
||||
expect(c.error.value).toBe('fail')
|
||||
})
|
||||
|
||||
it('deduplicates concurrent calls for the same house', async () => {
|
||||
mockApi.listLists.mockResolvedValue([makeList({ id: 1 })])
|
||||
|
||||
const c = useChecklists(houseCounter)
|
||||
const [a, b] = await Promise.all([c.load(), c.load()])
|
||||
|
||||
expect(a).toEqual(b)
|
||||
expect(mockApi.listLists).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
@@ -92,22 +108,37 @@ describe('useChecklists', () => {
|
||||
const newList = makeList({ id: 10 })
|
||||
mockApi.createList.mockResolvedValue(newList)
|
||||
|
||||
const c = useChecklists(1)
|
||||
const c = useChecklists(houseCounter)
|
||||
await c.load()
|
||||
const result = await c.create('New', 'desc', 'cart')
|
||||
|
||||
expect(mockApi.createList).toHaveBeenCalledWith(1, 'New', 'desc', 'cart')
|
||||
expect(mockApi.createList).toHaveBeenCalledWith(houseCounter, 'New', 'desc', 'cart')
|
||||
expect(result).toEqual(newList)
|
||||
expect(c.lists.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('replaces the updated list in state', async () => {
|
||||
const original = makeList({ id: 1, name: 'Old' })
|
||||
const updated = makeList({ id: 1, name: 'New' })
|
||||
mockApi.listLists.mockResolvedValue([original])
|
||||
mockApi.updateList.mockResolvedValue(updated)
|
||||
|
||||
const c = useChecklists(houseCounter)
|
||||
await c.load()
|
||||
await c.update(1, { name: 'New' })
|
||||
|
||||
expect(c.lists.value[0].name).toBe('New')
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes list from state', async () => {
|
||||
mockApi.listLists.mockResolvedValue([makeList({ id: 1 }), makeList({ id: 2 })])
|
||||
mockApi.deleteList.mockResolvedValue(undefined)
|
||||
|
||||
const c = useChecklists(1)
|
||||
const c = useChecklists(houseCounter)
|
||||
await c.load()
|
||||
await c.remove(1)
|
||||
|
||||
@@ -115,6 +146,57 @@ describe('useChecklists', () => {
|
||||
expect(c.lists.value[0].id).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared state', () => {
|
||||
it('two callers for the same house share the same lists ref', async () => {
|
||||
const lists = [makeList({ id: 1 }), makeList({ id: 2 })]
|
||||
mockApi.listLists.mockResolvedValue(lists)
|
||||
|
||||
const houseId = houseCounter
|
||||
const a = useChecklists(houseId)
|
||||
const b = useChecklists(houseId)
|
||||
|
||||
await a.load()
|
||||
|
||||
// Both consumers see the loaded lists even though only `a` triggered load.
|
||||
expect(a.lists.value).toEqual(lists)
|
||||
expect(b.lists.value).toEqual(lists)
|
||||
// And they reference the exact same ref instance.
|
||||
expect(a.lists).toBe(b.lists)
|
||||
})
|
||||
|
||||
it('propagates create across consumers for the same house', async () => {
|
||||
mockApi.listLists.mockResolvedValue([])
|
||||
const newList = makeList({ id: 10, name: 'Shared' })
|
||||
mockApi.createList.mockResolvedValue(newList)
|
||||
|
||||
const houseId = houseCounter
|
||||
const a = useChecklists(houseId)
|
||||
const b = useChecklists(houseId)
|
||||
|
||||
await a.load()
|
||||
await a.create('Shared')
|
||||
|
||||
expect(b.lists.value).toHaveLength(1)
|
||||
expect(b.lists.value[0].id).toBe(10)
|
||||
})
|
||||
|
||||
it('different house ids have independent state', async () => {
|
||||
mockApi.listLists.mockImplementation((id: number) =>
|
||||
Promise.resolve([makeList({ id: id * 100 })]),
|
||||
)
|
||||
|
||||
const a = useChecklists(houseCounter)
|
||||
houseCounter++
|
||||
const b = useChecklists(houseCounter)
|
||||
|
||||
await a.load()
|
||||
await b.load()
|
||||
|
||||
expect(a.lists.value).not.toEqual(b.lists.value)
|
||||
expect(a.lists.value[0].id).not.toBe(b.lists.value[0].id)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useChecklistItems', () => {
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
import { ref } from 'vue'
|
||||
import { ref, type Ref } from 'vue'
|
||||
import * as api from '@/api/lists'
|
||||
import type { Checklist, ChecklistItem } from '@/api/types'
|
||||
import type { ChecklistItemSort } from '@/api/prefs'
|
||||
|
||||
export function useChecklists(houseId: number) {
|
||||
const lists = ref<Checklist[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
// Per-house state shared across all callers so sidebar and views stay in sync.
|
||||
interface HouseChecklistState {
|
||||
lists: Ref<Checklist[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
inflight: Promise<Checklist[]> | null
|
||||
}
|
||||
const houseStates = new Map<number, HouseChecklistState>()
|
||||
|
||||
async function load(): Promise<void> {
|
||||
function getState(houseId: number): HouseChecklistState {
|
||||
let s = houseStates.get(houseId)
|
||||
if (!s) {
|
||||
s = {
|
||||
lists: ref<Checklist[]>([]),
|
||||
loading: ref(false),
|
||||
error: ref<string | null>(null),
|
||||
inflight: null,
|
||||
}
|
||||
houseStates.set(houseId, s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
export function useChecklists(houseId: number) {
|
||||
const state = getState(houseId)
|
||||
const { lists, loading, error } = state
|
||||
|
||||
function load(force = false): Promise<Checklist[]> {
|
||||
if (state.inflight && !force) return state.inflight
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
lists.value = await api.listLists(houseId)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
state.inflight = api
|
||||
.listLists(houseId)
|
||||
.then((result) => {
|
||||
lists.value = result
|
||||
return result
|
||||
})
|
||||
.catch((e) => {
|
||||
error.value = (e as Error).message
|
||||
return lists.value
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
state.inflight = null
|
||||
})
|
||||
return state.inflight
|
||||
}
|
||||
|
||||
async function create(
|
||||
|
||||
@@ -9,11 +9,28 @@
|
||||
<NcAppNavigationItem
|
||||
:name="strings.lists"
|
||||
:to="{ name: 'lists', params: { houseId: String(currentHouseId) } }"
|
||||
:active="isNavActive(['lists', 'list-detail', 'list-'])"
|
||||
:active="route.name === 'lists'"
|
||||
:allow-collapse="true"
|
||||
:open="listsExpanded"
|
||||
@update:open="listsExpanded = $event"
|
||||
>
|
||||
<template #icon>
|
||||
<ClipboardCheckIcon :size="20" />
|
||||
</template>
|
||||
<NcAppNavigationItem
|
||||
v-for="list in checklists"
|
||||
:key="list.id"
|
||||
:name="list.name"
|
||||
:to="{
|
||||
name: 'list-detail',
|
||||
params: { houseId: String(currentHouseId), listId: String(list.id) },
|
||||
}"
|
||||
:active="currentListId === list.id"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="checklistIconComponent(list.icon)" :size="18" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
</NcAppNavigationItem>
|
||||
|
||||
<NcAppNavigationItem
|
||||
@@ -173,6 +190,8 @@ import ChevronDownIcon from '@icons/ChevronDown.vue'
|
||||
import CheckIcon from '@icons/Check.vue'
|
||||
import PlusIcon from '@icons/Plus.vue'
|
||||
import { useHouses } from '@/composables/useHouses'
|
||||
import { useChecklists } from '@/composables/useChecklist'
|
||||
import { checklistIconComponent } from '@/components/ChecklistIconPicker'
|
||||
import HouseSettingsDialog from '@/components/HouseSettingsDialog'
|
||||
import AccountSettingsDialog from '@/components/AccountSettingsDialog'
|
||||
|
||||
@@ -187,9 +206,34 @@ const currentHouseId = computed<number | null>(() => {
|
||||
return Number.isFinite(id) ? id : null
|
||||
})
|
||||
|
||||
const currentListId = computed<number | null>(() => {
|
||||
const raw = route.params.listId
|
||||
if (!raw) return null
|
||||
const id = Number(Array.isArray(raw) ? raw[0] : raw)
|
||||
return Number.isFinite(id) ? id : null
|
||||
})
|
||||
|
||||
const house = computed(() =>
|
||||
currentHouseId.value !== null ? findById(currentHouseId.value) : undefined,
|
||||
)
|
||||
|
||||
// Checklists for the sidebar sub-items. The composable shares per-house
|
||||
// state, so creates/updates/deletes from other views are reflected here.
|
||||
const listsExpanded = ref(true)
|
||||
const checklists = computed(() => {
|
||||
const id = currentHouseId.value
|
||||
if (id === null) return []
|
||||
return useChecklists(id).lists.value
|
||||
})
|
||||
|
||||
watch(
|
||||
currentHouseId,
|
||||
(id) => {
|
||||
if (id === null) return
|
||||
void useChecklists(id).load()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
/**
|
||||
* Prefix-based route matcher for sidebar items. An item is active when the
|
||||
* current route name equals any of the given prefixes, or starts with one of
|
||||
|
||||
Reference in New Issue
Block a user