Skip to content

Commit faefd03

Browse files
authored
Merge pull request #7018 from LibreSign/feat/file-list-filters
feat: file list filters
2 parents 586dcff + 345d25f commit faefd03

21 files changed

Lines changed: 1503 additions & 153 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors
4+
* SPDX-License-Identifier: AGPL-3.0-or-later
5+
*/
6+
7+
import { computed, readonly, ref } from '@vue/reactivity'
8+
9+
/** The element we observe */
10+
let element: HTMLElement | undefined
11+
12+
/** The current width of the element */
13+
const width = ref<number>(0)
14+
15+
const isWide = computed(() => width.value >= 1024)
16+
const isMedium = computed(() => width.value >= 512 && width.value < 1024)
17+
const isNarrow = computed(() => width.value < 512)
18+
19+
const observer = new ResizeObserver(([el]) => {
20+
if (!el) {
21+
return
22+
}
23+
24+
const contentBoxSize = el.contentBoxSize?.[0]
25+
if (contentBoxSize) {
26+
// use the newer `contentBoxSize` property if available
27+
width.value = contentBoxSize.inlineSize
28+
} else {
29+
// fall back to `contentRect`
30+
width.value = el.contentRect.width
31+
}
32+
})
33+
34+
/**
35+
* Update the observed element if needed and reconfigure the observer
36+
*/
37+
function updateObserver() {
38+
const el = document.querySelector<HTMLElement>('#app-content-vue') ?? document.body
39+
if (el !== element) {
40+
// if already observing: stop observing the old element
41+
if (element) {
42+
observer.unobserve(element)
43+
}
44+
// observe the new element
45+
observer.observe(el)
46+
element = el
47+
}
48+
}
49+
50+
/**
51+
* Get the reactive width of the file list
52+
*/
53+
export function useFileListWidth() {
54+
// Update the observer in setup context so we already have an initial value
55+
updateObserver()
56+
57+
return {
58+
width: readonly(width),
59+
60+
isWide,
61+
isMedium,
62+
isNarrow,
63+
}
64+
}

src/store/files.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -567,16 +567,14 @@ export const useFilesStore = function(...args) {
567567
params.set(key, value)
568568
}
569569
}
570-
const { chips } = useFiltersStore()
571-
if (chips?.status) {
572-
chips.status.forEach(status => {
573-
params.append('status[]', status.id)
574-
})
575-
}
576-
if (chips?.modified?.length) {
577-
const { start, end } = chips.modified[0]
578-
params.set('start', Math.floor(start / 1000))
579-
params.set('end', Math.floor(end / 1000))
570+
const filtersStore = useFiltersStore()
571+
filtersStore.filterStatusArray.forEach(id => {
572+
params.append('status[]', id)
573+
})
574+
const modifiedRange = filtersStore.filterModifiedRange
575+
if (modifiedRange) {
576+
params.set('start', Math.floor(modifiedRange.start / 1000))
577+
params.set('end', Math.floor(modifiedRange.end / 1000))
580578
}
581579
const { sortingMode, sortingDirection } = useFilesSortingStore()
582580
if (sortingMode) {

src/store/filters.js

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import { loadState } from '@nextcloud/initial-state'
1010
import axios from '@nextcloud/axios'
1111
import { generateOcsUrl } from '@nextcloud/router'
1212
import logger from '../helpers/logger'
13+
import { getTimePresetRange } from '../utils/timePresets.js'
1314

1415
export const useFiltersStore = defineStore('filter', {
1516
state: () => ({
1617
chips: {},
17-
filter_modified: loadState('libresign', 'filters', { filter_modified: '' }).filter_modified,
18-
filter_status: loadState('libresign', 'filters', { filter_status: '' }).filter_status,
18+
filter_modified: loadState('libresign', 'filters', {}).files_list_filter_modified ?? '',
19+
filter_status: loadState('libresign', 'filters', {}).files_list_filter_status ?? ''
1920
}),
2021

2122
getters: {
@@ -29,13 +30,20 @@ export const useFiltersStore = defineStore('filter', {
2930
return []
3031
}
3132
},
33+
/**
34+
* Returns { start, end } in ms for the saved modified preset, or null.
35+
* Computed fresh on each access so date boundaries are always current.
36+
*/
37+
filterModifiedRange(state) {
38+
return getTimePresetRange(state.filter_modified)
39+
},
3240
},
3341

42+
3443
actions: {
3544
async onFilterUpdateChips(event) {
3645
this.chips = { ...this.chips, [event.id]: [...event.detail] }
3746

38-
emit('libresign:filters:update')
3947
logger.debug('File list filter chips updated', { chips: event.detail })
4048

4149
},
@@ -47,18 +55,20 @@ export const useFiltersStore = defineStore('filter', {
4755
if(event.id == 'modified'){
4856
let value = this.chips['modified'][0]?.id || '';
4957

50-
await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'filter_modified' }), {
58+
await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'files_list_filter_modified' }), {
5159
value,
5260
})
5361

62+
this.filter_modified = value
63+
5464
emit('libresign:filters:update')
5565
}
5666

5767
if(event.id == 'status'){
5868

5969
const value = event.detail.length > 0 ? JSON.stringify(event.detail.map(item => item.id)) : '';
6070

61-
await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'filter_status' }), {
71+
await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'files_list_filter_status' }), {
6272
value,
6373
})
6474

src/tests/components/Signers/Signer.spec.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,18 @@ type FilesStoreMock = ReturnType<typeof useFilesStore> & {
3333
isOriginalFileDeleted: MockedFunction<() => boolean>
3434
}
3535

36-
const t: TranslationFunction = (_app, text, vars) => {
37-
if (vars) {
38-
return text.replace(/{(\w+)}/g, (_m, key) => String(vars[key]))
36+
const { t, n } = vi.hoisted(() => {
37+
const t: TranslationFunction = (_app, text, vars) => {
38+
if (vars) {
39+
return text.replace(/{(\w+)}/g, (_m, key) => String(vars[key]))
40+
}
41+
return text
3942
}
40-
return text
41-
}
4243

43-
const n: PluralTranslationFunction = (_app, singular, plural, count) => (count === 1 ? singular : plural)
44+
const n: PluralTranslationFunction = (_app, singular, plural, count) => (count === 1 ? singular : plural)
45+
46+
return { t, n }
47+
})
4448

4549
let Signer: SignerComponent
4650

src/tests/store/files.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,12 @@ vi.mock('./filesSorting.js', () => ({
107107
}))
108108

109109
vi.mock('./filters.js', () => ({
110-
useFiltersStore: vi.fn(() => ({ filters: {} })),
110+
useFiltersStore: vi.fn(() => ({
111+
filterStatusArray: [],
112+
filterModifiedRange: null,
113+
filter_modified: '',
114+
filter_status: '',
115+
})),
111116
}))
112117

113118
vi.mock('./identificationDocument.js', () => ({

src/tests/store/filters.spec.ts

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,43 @@ describe('filters store - filter business rules', () => {
7878
useFiltersStore = module.useFiltersStore
7979
})
8080

81+
describe('business rule: state should be initialised from PHP initial state using files_list_filter_* keys', () => {
82+
beforeEach(() => {
83+
vi.resetModules()
84+
})
85+
86+
it('reads filter_status from files_list_filter_status key', async () => {
87+
const loadStateMock = loadState as MockedFunction<typeof loadState>
88+
loadStateMock.mockReturnValue({ files_list_filter_status: '["signed"]', files_list_filter_modified: '' })
89+
90+
const { useFiltersStore: freshStore } = await import('../../store/filters.js')
91+
const store = freshStore()
92+
93+
expect(store.filter_status).toBe('["signed"]')
94+
})
95+
96+
it('reads filter_modified from files_list_filter_modified key', async () => {
97+
const loadStateMock = loadState as MockedFunction<typeof loadState>
98+
loadStateMock.mockReturnValue({ files_list_filter_status: '', files_list_filter_modified: 'last-7' })
99+
100+
const { useFiltersStore: freshStore } = await import('../../store/filters.js')
101+
const store = freshStore()
102+
103+
expect(store.filter_modified).toBe('last-7')
104+
})
105+
106+
it('defaults to empty string when keys are absent', async () => {
107+
const loadStateMock = loadState as MockedFunction<typeof loadState>
108+
loadStateMock.mockReturnValue({})
109+
110+
const { useFiltersStore: freshStore } = await import('../../store/filters.js')
111+
const store = freshStore()
112+
113+
expect(store.filter_status).toBe('')
114+
expect(store.filter_modified).toBe('')
115+
})
116+
})
117+
81118
describe('business rule: activeChips should return all active chips from all filters', () => {
82119
it('returns empty array when there are no chips', () => {
83120
const store = useFiltersStore()
@@ -157,8 +194,49 @@ describe('filters store - filter business rules', () => {
157194
})
158195
})
159196

160-
describe('business rule: chips update should emit filter event', () => {
161-
it('onFilterUpdateChips should emit libresign:filters:update event', async () => {
197+
describe('business rule: filterModifiedRange should compute date range from preset id', () => {
198+
it('returns null when filter_modified is empty', () => {
199+
const store = useFiltersStore()
200+
store.filter_modified = ''
201+
202+
expect(store.filterModifiedRange).toBeNull()
203+
})
204+
205+
it('returns null for unknown preset id', () => {
206+
const store = useFiltersStore()
207+
store.filter_modified = 'unknown-preset'
208+
209+
expect(store.filterModifiedRange).toBeNull()
210+
})
211+
212+
it.each(['today', 'last-7', 'last-30', 'this-year', 'last-year'])('returns { start, end } for preset "%s"', (presetId) => {
213+
const store = useFiltersStore()
214+
store.filter_modified = presetId
215+
216+
const range = store.filterModifiedRange
217+
expect(range).not.toBeNull()
218+
expect(range?.start).toBeTypeOf('number')
219+
expect(range?.end).toBeTypeOf('number')
220+
expect(range!.start).toBeLessThan(range!.end)
221+
})
222+
223+
it('today preset: start is midnight and end is end of day', () => {
224+
const store = useFiltersStore()
225+
store.filter_modified = 'today'
226+
227+
const range = store.filterModifiedRange!
228+
const startDate = new Date(range.start)
229+
const endDate = new Date(range.end)
230+
231+
expect(startDate.getHours()).toBe(0)
232+
expect(startDate.getMinutes()).toBe(0)
233+
expect(endDate.getHours()).toBe(23)
234+
expect(endDate.getMinutes()).toBe(59)
235+
})
236+
})
237+
238+
describe('business rule: chips update should only update UI chips state', () => {
239+
it('onFilterUpdateChips should NOT emit libresign:filters:update event', async () => {
162240
const store = useFiltersStore()
163241
const event = {
164242
id: 'status',
@@ -167,7 +245,7 @@ describe('filters store - filter business rules', () => {
167245

168246
await store.onFilterUpdateChips(event)
169247

170-
expect(emit).toHaveBeenCalledWith('libresign:filters:update')
248+
expect(emit).not.toHaveBeenCalled()
171249
})
172250

173251
it('onFilterUpdateChips should update chips for specific filter', async () => {
@@ -213,7 +291,7 @@ describe('filters store - filter business rules', () => {
213291
await store.onFilterUpdateChipsAndSave(event)
214292

215293
expect(axiosMock.put).toHaveBeenCalledWith(
216-
'/ocs/v2.php/apps/libresign/api/v1/account/config/filter_modified',
294+
'/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_modified',
217295
{ value: 'today' }
218296
)
219297
})
@@ -233,7 +311,7 @@ describe('filters store - filter business rules', () => {
233311
await store.onFilterUpdateChipsAndSave(event)
234312

235313
expect(axiosMock.put).toHaveBeenCalledWith(
236-
'/ocs/v2.php/apps/libresign/api/v1/account/config/filter_modified',
314+
'/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_modified',
237315
{ value: 'today' }
238316
)
239317
})
@@ -250,10 +328,39 @@ describe('filters store - filter business rules', () => {
250328
await store.onFilterUpdateChipsAndSave(event)
251329

252330
expect(axiosMock.put).toHaveBeenCalledWith(
253-
'/ocs/v2.php/apps/libresign/api/v1/account/config/filter_modified',
331+
'/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_modified',
254332
{ value: '' }
255333
)
256334
})
335+
336+
it('modified filter should update local state after saving', async () => {
337+
axiosMock.put.mockResolvedValue({ data: { success: true } })
338+
339+
const store = useFiltersStore()
340+
const event = {
341+
id: 'modified',
342+
detail: [{ id: 'today', label: 'Today' }],
343+
}
344+
345+
await store.onFilterUpdateChipsAndSave(event)
346+
347+
expect(store.filter_modified).toBe('today')
348+
})
349+
350+
it('empty modified filter should clear local state', async () => {
351+
axiosMock.put.mockResolvedValue({ data: { success: true } })
352+
353+
const store = useFiltersStore()
354+
store.filter_modified = 'last-7'
355+
const event = {
356+
id: 'modified',
357+
detail: [],
358+
}
359+
360+
await store.onFilterUpdateChipsAndSave(event)
361+
362+
expect(store.filter_modified).toBe('')
363+
})
257364
})
258365

259366
describe('business rule: status filter should save JSON array to server', () => {
@@ -272,7 +379,7 @@ describe('filters store - filter business rules', () => {
272379
await store.onFilterUpdateChipsAndSave(event)
273380

274381
expect(axiosMock.put).toHaveBeenCalledWith(
275-
'/ocs/v2.php/apps/libresign/api/v1/account/config/filter_status',
382+
'/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_status',
276383
{ value: '["signed","pending"]' }
277384
)
278385
})
@@ -289,7 +396,7 @@ describe('filters store - filter business rules', () => {
289396
await store.onFilterUpdateChipsAndSave(event)
290397

291398
expect(axiosMock.put).toHaveBeenCalledWith(
292-
'/ocs/v2.php/apps/libresign/api/v1/account/config/filter_status',
399+
'/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_status',
293400
{ value: '' }
294401
)
295402
})

0 commit comments

Comments
 (0)