Skip to content

Commit 3c2f5a2

Browse files
committed
feat: checklists in sidebar
1 parent 9fd9616 commit 3c2f5a2

3 files changed

Lines changed: 175 additions & 18 deletions

File tree

src/composables/useChecklist.test.ts

Lines changed: 87 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,23 @@ function makeItem(overrides: Partial<ChecklistItem> = {}): ChecklistItem {
5858
}
5959
}
6060

61+
// Each test uses a unique houseId so module-level shared state doesn't leak
62+
// between tests. That is also what the production sharing guarantees — same
63+
// houseId → same state, different houseId → independent state.
64+
let houseCounter = 100
65+
6166
describe('useChecklists', () => {
6267
beforeEach(() => {
6368
vi.resetAllMocks()
69+
houseCounter++
6470
})
6571

6672
describe('load', () => {
6773
it('loads lists', async () => {
6874
const lists = [makeList({ id: 1 }), makeList({ id: 2 })]
6975
mockApi.listLists.mockResolvedValue(lists)
7076

71-
const c = useChecklists(1)
77+
const c = useChecklists(houseCounter)
7278
await c.load()
7379

7480
expect(c.lists.value).toEqual(lists)
@@ -79,11 +85,21 @@ describe('useChecklists', () => {
7985
it('sets error on failure', async () => {
8086
mockApi.listLists.mockRejectedValue(new Error('fail'))
8187

82-
const c = useChecklists(1)
88+
const c = useChecklists(houseCounter)
8389
await c.load()
8490

8591
expect(c.error.value).toBe('fail')
8692
})
93+
94+
it('deduplicates concurrent calls for the same house', async () => {
95+
mockApi.listLists.mockResolvedValue([makeList({ id: 1 })])
96+
97+
const c = useChecklists(houseCounter)
98+
const [a, b] = await Promise.all([c.load(), c.load()])
99+
100+
expect(a).toEqual(b)
101+
expect(mockApi.listLists).toHaveBeenCalledTimes(1)
102+
})
87103
})
88104

89105
describe('create', () => {
@@ -92,29 +108,95 @@ describe('useChecklists', () => {
92108
const newList = makeList({ id: 10 })
93109
mockApi.createList.mockResolvedValue(newList)
94110

95-
const c = useChecklists(1)
111+
const c = useChecklists(houseCounter)
96112
await c.load()
97113
const result = await c.create('New', 'desc', 'cart')
98114

99-
expect(mockApi.createList).toHaveBeenCalledWith(1, 'New', 'desc', 'cart')
115+
expect(mockApi.createList).toHaveBeenCalledWith(houseCounter, 'New', 'desc', 'cart')
100116
expect(result).toEqual(newList)
101117
expect(c.lists.value).toHaveLength(1)
102118
})
103119
})
104120

121+
describe('update', () => {
122+
it('replaces the updated list in state', async () => {
123+
const original = makeList({ id: 1, name: 'Old' })
124+
const updated = makeList({ id: 1, name: 'New' })
125+
mockApi.listLists.mockResolvedValue([original])
126+
mockApi.updateList.mockResolvedValue(updated)
127+
128+
const c = useChecklists(houseCounter)
129+
await c.load()
130+
await c.update(1, { name: 'New' })
131+
132+
expect(c.lists.value[0].name).toBe('New')
133+
})
134+
})
135+
105136
describe('remove', () => {
106137
it('removes list from state', async () => {
107138
mockApi.listLists.mockResolvedValue([makeList({ id: 1 }), makeList({ id: 2 })])
108139
mockApi.deleteList.mockResolvedValue(undefined)
109140

110-
const c = useChecklists(1)
141+
const c = useChecklists(houseCounter)
111142
await c.load()
112143
await c.remove(1)
113144

114145
expect(c.lists.value).toHaveLength(1)
115146
expect(c.lists.value[0].id).toBe(2)
116147
})
117148
})
149+
150+
describe('shared state', () => {
151+
it('two callers for the same house share the same lists ref', async () => {
152+
const lists = [makeList({ id: 1 }), makeList({ id: 2 })]
153+
mockApi.listLists.mockResolvedValue(lists)
154+
155+
const houseId = houseCounter
156+
const a = useChecklists(houseId)
157+
const b = useChecklists(houseId)
158+
159+
await a.load()
160+
161+
// Both consumers see the loaded lists even though only `a` triggered load.
162+
expect(a.lists.value).toEqual(lists)
163+
expect(b.lists.value).toEqual(lists)
164+
// And they reference the exact same ref instance.
165+
expect(a.lists).toBe(b.lists)
166+
})
167+
168+
it('propagates create across consumers for the same house', async () => {
169+
mockApi.listLists.mockResolvedValue([])
170+
const newList = makeList({ id: 10, name: 'Shared' })
171+
mockApi.createList.mockResolvedValue(newList)
172+
173+
const houseId = houseCounter
174+
const a = useChecklists(houseId)
175+
const b = useChecklists(houseId)
176+
177+
await a.load()
178+
await a.create('Shared')
179+
180+
expect(b.lists.value).toHaveLength(1)
181+
expect(b.lists.value[0].id).toBe(10)
182+
})
183+
184+
it('different house ids have independent state', async () => {
185+
mockApi.listLists.mockImplementation((id: number) =>
186+
Promise.resolve([makeList({ id: id * 100 })]),
187+
)
188+
189+
const a = useChecklists(houseCounter)
190+
houseCounter++
191+
const b = useChecklists(houseCounter)
192+
193+
await a.load()
194+
await b.load()
195+
196+
expect(a.lists.value).not.toEqual(b.lists.value)
197+
expect(a.lists.value[0].id).not.toBe(b.lists.value[0].id)
198+
})
199+
})
118200
})
119201

120202
describe('useChecklistItems', () => {

src/composables/useChecklist.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,54 @@
1-
import { ref } from 'vue'
1+
import { ref, type Ref } from 'vue'
22
import * as api from '@/api/lists'
33
import type { Checklist, ChecklistItem } from '@/api/types'
44
import type { ChecklistItemSort } from '@/api/prefs'
55

6+
// Per-house state shared across all callers so sidebar and views stay in sync.
7+
interface HouseChecklistState {
8+
lists: Ref<Checklist[]>
9+
loading: Ref<boolean>
10+
error: Ref<string | null>
11+
inflight: Promise<Checklist[]> | null
12+
}
13+
const houseStates = new Map<number, HouseChecklistState>()
14+
15+
function getState(houseId: number): HouseChecklistState {
16+
let s = houseStates.get(houseId)
17+
if (!s) {
18+
s = {
19+
lists: ref<Checklist[]>([]),
20+
loading: ref(false),
21+
error: ref<string | null>(null),
22+
inflight: null,
23+
}
24+
houseStates.set(houseId, s)
25+
}
26+
return s
27+
}
28+
629
export function useChecklists(houseId: number) {
7-
const lists = ref<Checklist[]>([])
8-
const loading = ref(false)
9-
const error = ref<string | null>(null)
30+
const state = getState(houseId)
31+
const { lists, loading, error } = state
1032

11-
async function load(): Promise<void> {
33+
function load(force = false): Promise<Checklist[]> {
34+
if (state.inflight && !force) return state.inflight
1235
loading.value = true
1336
error.value = null
14-
try {
15-
lists.value = await api.listLists(houseId)
16-
} catch (e) {
17-
error.value = (e as Error).message
18-
} finally {
19-
loading.value = false
20-
}
37+
state.inflight = api
38+
.listLists(houseId)
39+
.then((result) => {
40+
lists.value = result
41+
return result
42+
})
43+
.catch((e) => {
44+
error.value = (e as Error).message
45+
return lists.value
46+
})
47+
.finally(() => {
48+
loading.value = false
49+
state.inflight = null
50+
})
51+
return state.inflight
2152
}
2253

2354
async function create(

src/views/SideNavigation.vue

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,28 @@
99
<NcAppNavigationItem
1010
:name="strings.lists"
1111
:to="{ name: 'lists', params: { houseId: String(currentHouseId) } }"
12-
:active="isNavActive(['lists', 'list-detail', 'list-'])"
12+
:active="route.name === 'lists'"
13+
:allow-collapse="true"
14+
:open="listsExpanded"
15+
@update:open="listsExpanded = $event"
1316
>
1417
<template #icon>
1518
<ClipboardCheckIcon :size="20" />
1619
</template>
20+
<NcAppNavigationItem
21+
v-for="list in checklists"
22+
:key="list.id"
23+
:name="list.name"
24+
:to="{
25+
name: 'list-detail',
26+
params: { houseId: String(currentHouseId), listId: String(list.id) },
27+
}"
28+
:active="currentListId === list.id"
29+
>
30+
<template #icon>
31+
<component :is="checklistIconComponent(list.icon)" :size="18" />
32+
</template>
33+
</NcAppNavigationItem>
1734
</NcAppNavigationItem>
1835

1936
<NcAppNavigationItem
@@ -173,6 +190,8 @@ import ChevronDownIcon from '@icons/ChevronDown.vue'
173190
import CheckIcon from '@icons/Check.vue'
174191
import PlusIcon from '@icons/Plus.vue'
175192
import { useHouses } from '@/composables/useHouses'
193+
import { useChecklists } from '@/composables/useChecklist'
194+
import { checklistIconComponent } from '@/components/ChecklistIconPicker'
176195
import HouseSettingsDialog from '@/components/HouseSettingsDialog'
177196
import AccountSettingsDialog from '@/components/AccountSettingsDialog'
178197
@@ -187,9 +206,34 @@ const currentHouseId = computed<number | null>(() => {
187206
return Number.isFinite(id) ? id : null
188207
})
189208
209+
const currentListId = computed<number | null>(() => {
210+
const raw = route.params.listId
211+
if (!raw) return null
212+
const id = Number(Array.isArray(raw) ? raw[0] : raw)
213+
return Number.isFinite(id) ? id : null
214+
})
215+
190216
const house = computed(() =>
191217
currentHouseId.value !== null ? findById(currentHouseId.value) : undefined,
192218
)
219+
220+
// Checklists for the sidebar sub-items. The composable shares per-house
221+
// state, so creates/updates/deletes from other views are reflected here.
222+
const listsExpanded = ref(true)
223+
const checklists = computed(() => {
224+
const id = currentHouseId.value
225+
if (id === null) return []
226+
return useChecklists(id).lists.value
227+
})
228+
229+
watch(
230+
currentHouseId,
231+
(id) => {
232+
if (id === null) return
233+
void useChecklists(id).load()
234+
},
235+
{ immediate: true },
236+
)
193237
/**
194238
* Prefix-based route matcher for sidebar items. An item is active when the
195239
* current route name equals any of the given prefixes, or starts with one of

0 commit comments

Comments
 (0)