feat: checklists in sidebar

This commit is contained in:
2026-04-11 19:19:28 +03:00
parent 9fd9616415
commit 3c2f5a2766
3 changed files with 176 additions and 19 deletions

View File

@@ -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', () => {

View File

@@ -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(

View File

@@ -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