diff --git a/src/composables/useFileListWidth.ts b/src/composables/useFileListWidth.ts new file mode 100644 index 0000000000..0209189768 --- /dev/null +++ b/src/composables/useFileListWidth.ts @@ -0,0 +1,64 @@ +/*! + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { computed, readonly, ref } from '@vue/reactivity' + +/** The element we observe */ +let element: HTMLElement | undefined + +/** The current width of the element */ +const width = ref(0) + +const isWide = computed(() => width.value >= 1024) +const isMedium = computed(() => width.value >= 512 && width.value < 1024) +const isNarrow = computed(() => width.value < 512) + +const observer = new ResizeObserver(([el]) => { + if (!el) { + return + } + + const contentBoxSize = el.contentBoxSize?.[0] + if (contentBoxSize) { + // use the newer `contentBoxSize` property if available + width.value = contentBoxSize.inlineSize + } else { + // fall back to `contentRect` + width.value = el.contentRect.width + } +}) + +/** + * Update the observed element if needed and reconfigure the observer + */ +function updateObserver() { + const el = document.querySelector('#app-content-vue') ?? document.body + if (el !== element) { + // if already observing: stop observing the old element + if (element) { + observer.unobserve(element) + } + // observe the new element + observer.observe(el) + element = el + } +} + +/** + * Get the reactive width of the file list + */ +export function useFileListWidth() { + // Update the observer in setup context so we already have an initial value + updateObserver() + + return { + width: readonly(width), + + isWide, + isMedium, + isNarrow, + } +} diff --git a/src/store/files.js b/src/store/files.js index e5438c1f6a..7239a23e44 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -567,16 +567,14 @@ export const useFilesStore = function(...args) { params.set(key, value) } } - const { chips } = useFiltersStore() - if (chips?.status) { - chips.status.forEach(status => { - params.append('status[]', status.id) - }) - } - if (chips?.modified?.length) { - const { start, end } = chips.modified[0] - params.set('start', Math.floor(start / 1000)) - params.set('end', Math.floor(end / 1000)) + const filtersStore = useFiltersStore() + filtersStore.filterStatusArray.forEach(id => { + params.append('status[]', id) + }) + const modifiedRange = filtersStore.filterModifiedRange + if (modifiedRange) { + params.set('start', Math.floor(modifiedRange.start / 1000)) + params.set('end', Math.floor(modifiedRange.end / 1000)) } const { sortingMode, sortingDirection } = useFilesSortingStore() if (sortingMode) { diff --git a/src/store/filters.js b/src/store/filters.js index e095be3f2a..fdb480146e 100644 --- a/src/store/filters.js +++ b/src/store/filters.js @@ -10,12 +10,13 @@ import { loadState } from '@nextcloud/initial-state' import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' import logger from '../helpers/logger' +import { getTimePresetRange } from '../utils/timePresets.js' export const useFiltersStore = defineStore('filter', { state: () => ({ chips: {}, - filter_modified: loadState('libresign', 'filters', { filter_modified: '' }).filter_modified, - filter_status: loadState('libresign', 'filters', { filter_status: '' }).filter_status, + filter_modified: loadState('libresign', 'filters', {}).files_list_filter_modified ?? '', + filter_status: loadState('libresign', 'filters', {}).files_list_filter_status ?? '' }), getters: { @@ -29,13 +30,20 @@ export const useFiltersStore = defineStore('filter', { return [] } }, + /** + * Returns { start, end } in ms for the saved modified preset, or null. + * Computed fresh on each access so date boundaries are always current. + */ + filterModifiedRange(state) { + return getTimePresetRange(state.filter_modified) + }, }, + actions: { async onFilterUpdateChips(event) { this.chips = { ...this.chips, [event.id]: [...event.detail] } - emit('libresign:filters:update') logger.debug('File list filter chips updated', { chips: event.detail }) }, @@ -47,10 +55,12 @@ export const useFiltersStore = defineStore('filter', { if(event.id == 'modified'){ let value = this.chips['modified'][0]?.id || ''; - await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'filter_modified' }), { + await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'files_list_filter_modified' }), { value, }) + this.filter_modified = value + emit('libresign:filters:update') } @@ -58,7 +68,7 @@ export const useFiltersStore = defineStore('filter', { const value = event.detail.length > 0 ? JSON.stringify(event.detail.map(item => item.id)) : ''; - await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'filter_status' }), { + await axios.put(generateOcsUrl('/apps/libresign/api/v1/account/config/{key}', { key: 'files_list_filter_status' }), { value, }) diff --git a/src/tests/components/Signers/Signer.spec.ts b/src/tests/components/Signers/Signer.spec.ts index f4382cbf56..21d43f3b4e 100644 --- a/src/tests/components/Signers/Signer.spec.ts +++ b/src/tests/components/Signers/Signer.spec.ts @@ -33,14 +33,18 @@ type FilesStoreMock = ReturnType & { isOriginalFileDeleted: MockedFunction<() => boolean> } -const t: TranslationFunction = (_app, text, vars) => { - if (vars) { - return text.replace(/{(\w+)}/g, (_m, key) => String(vars[key])) +const { t, n } = vi.hoisted(() => { + const t: TranslationFunction = (_app, text, vars) => { + if (vars) { + return text.replace(/{(\w+)}/g, (_m, key) => String(vars[key])) + } + return text } - return text -} -const n: PluralTranslationFunction = (_app, singular, plural, count) => (count === 1 ? singular : plural) + const n: PluralTranslationFunction = (_app, singular, plural, count) => (count === 1 ? singular : plural) + + return { t, n } +}) let Signer: SignerComponent diff --git a/src/tests/store/files.spec.ts b/src/tests/store/files.spec.ts index 3e60724bbc..1d719e5761 100644 --- a/src/tests/store/files.spec.ts +++ b/src/tests/store/files.spec.ts @@ -107,7 +107,12 @@ vi.mock('./filesSorting.js', () => ({ })) vi.mock('./filters.js', () => ({ - useFiltersStore: vi.fn(() => ({ filters: {} })), + useFiltersStore: vi.fn(() => ({ + filterStatusArray: [], + filterModifiedRange: null, + filter_modified: '', + filter_status: '', + })), })) vi.mock('./identificationDocument.js', () => ({ diff --git a/src/tests/store/filters.spec.ts b/src/tests/store/filters.spec.ts index 6004877955..07ee96d52d 100644 --- a/src/tests/store/filters.spec.ts +++ b/src/tests/store/filters.spec.ts @@ -78,6 +78,43 @@ describe('filters store - filter business rules', () => { useFiltersStore = module.useFiltersStore }) + describe('business rule: state should be initialised from PHP initial state using files_list_filter_* keys', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('reads filter_status from files_list_filter_status key', async () => { + const loadStateMock = loadState as MockedFunction + loadStateMock.mockReturnValue({ files_list_filter_status: '["signed"]', files_list_filter_modified: '' }) + + const { useFiltersStore: freshStore } = await import('../../store/filters.js') + const store = freshStore() + + expect(store.filter_status).toBe('["signed"]') + }) + + it('reads filter_modified from files_list_filter_modified key', async () => { + const loadStateMock = loadState as MockedFunction + loadStateMock.mockReturnValue({ files_list_filter_status: '', files_list_filter_modified: 'last-7' }) + + const { useFiltersStore: freshStore } = await import('../../store/filters.js') + const store = freshStore() + + expect(store.filter_modified).toBe('last-7') + }) + + it('defaults to empty string when keys are absent', async () => { + const loadStateMock = loadState as MockedFunction + loadStateMock.mockReturnValue({}) + + const { useFiltersStore: freshStore } = await import('../../store/filters.js') + const store = freshStore() + + expect(store.filter_status).toBe('') + expect(store.filter_modified).toBe('') + }) + }) + describe('business rule: activeChips should return all active chips from all filters', () => { it('returns empty array when there are no chips', () => { const store = useFiltersStore() @@ -157,8 +194,49 @@ describe('filters store - filter business rules', () => { }) }) - describe('business rule: chips update should emit filter event', () => { - it('onFilterUpdateChips should emit libresign:filters:update event', async () => { + describe('business rule: filterModifiedRange should compute date range from preset id', () => { + it('returns null when filter_modified is empty', () => { + const store = useFiltersStore() + store.filter_modified = '' + + expect(store.filterModifiedRange).toBeNull() + }) + + it('returns null for unknown preset id', () => { + const store = useFiltersStore() + store.filter_modified = 'unknown-preset' + + expect(store.filterModifiedRange).toBeNull() + }) + + it.each(['today', 'last-7', 'last-30', 'this-year', 'last-year'])('returns { start, end } for preset "%s"', (presetId) => { + const store = useFiltersStore() + store.filter_modified = presetId + + const range = store.filterModifiedRange + expect(range).not.toBeNull() + expect(range?.start).toBeTypeOf('number') + expect(range?.end).toBeTypeOf('number') + expect(range!.start).toBeLessThan(range!.end) + }) + + it('today preset: start is midnight and end is end of day', () => { + const store = useFiltersStore() + store.filter_modified = 'today' + + const range = store.filterModifiedRange! + const startDate = new Date(range.start) + const endDate = new Date(range.end) + + expect(startDate.getHours()).toBe(0) + expect(startDate.getMinutes()).toBe(0) + expect(endDate.getHours()).toBe(23) + expect(endDate.getMinutes()).toBe(59) + }) + }) + + describe('business rule: chips update should only update UI chips state', () => { + it('onFilterUpdateChips should NOT emit libresign:filters:update event', async () => { const store = useFiltersStore() const event = { id: 'status', @@ -167,7 +245,7 @@ describe('filters store - filter business rules', () => { await store.onFilterUpdateChips(event) - expect(emit).toHaveBeenCalledWith('libresign:filters:update') + expect(emit).not.toHaveBeenCalled() }) it('onFilterUpdateChips should update chips for specific filter', async () => { @@ -213,7 +291,7 @@ describe('filters store - filter business rules', () => { await store.onFilterUpdateChipsAndSave(event) expect(axiosMock.put).toHaveBeenCalledWith( - '/ocs/v2.php/apps/libresign/api/v1/account/config/filter_modified', + '/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_modified', { value: 'today' } ) }) @@ -233,7 +311,7 @@ describe('filters store - filter business rules', () => { await store.onFilterUpdateChipsAndSave(event) expect(axiosMock.put).toHaveBeenCalledWith( - '/ocs/v2.php/apps/libresign/api/v1/account/config/filter_modified', + '/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_modified', { value: 'today' } ) }) @@ -250,10 +328,39 @@ describe('filters store - filter business rules', () => { await store.onFilterUpdateChipsAndSave(event) expect(axiosMock.put).toHaveBeenCalledWith( - '/ocs/v2.php/apps/libresign/api/v1/account/config/filter_modified', + '/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_modified', { value: '' } ) }) + + it('modified filter should update local state after saving', async () => { + axiosMock.put.mockResolvedValue({ data: { success: true } }) + + const store = useFiltersStore() + const event = { + id: 'modified', + detail: [{ id: 'today', label: 'Today' }], + } + + await store.onFilterUpdateChipsAndSave(event) + + expect(store.filter_modified).toBe('today') + }) + + it('empty modified filter should clear local state', async () => { + axiosMock.put.mockResolvedValue({ data: { success: true } }) + + const store = useFiltersStore() + store.filter_modified = 'last-7' + const event = { + id: 'modified', + detail: [], + } + + await store.onFilterUpdateChipsAndSave(event) + + expect(store.filter_modified).toBe('') + }) }) describe('business rule: status filter should save JSON array to server', () => { @@ -272,7 +379,7 @@ describe('filters store - filter business rules', () => { await store.onFilterUpdateChipsAndSave(event) expect(axiosMock.put).toHaveBeenCalledWith( - '/ocs/v2.php/apps/libresign/api/v1/account/config/filter_status', + '/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_status', { value: '["signed","pending"]' } ) }) @@ -289,7 +396,7 @@ describe('filters store - filter business rules', () => { await store.onFilterUpdateChipsAndSave(event) expect(axiosMock.put).toHaveBeenCalledWith( - '/ocs/v2.php/apps/libresign/api/v1/account/config/filter_status', + '/ocs/v2.php/apps/libresign/api/v1/account/config/files_list_filter_status', { value: '' } ) }) diff --git a/src/tests/utils/timePresets.spec.ts b/src/tests/utils/timePresets.spec.ts new file mode 100644 index 0000000000..5b9a451895 --- /dev/null +++ b/src/tests/utils/timePresets.spec.ts @@ -0,0 +1,153 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getTimePresetRange, getTimePresets } from '../../utils/timePresets.js' + +vi.mock('@nextcloud/l10n', () => ({ + t: (_app: string, text: string) => text, +})) + +describe('getTimePresets', () => { + describe('business rule: should return all 5 presets with correct structure', () => { + it('returns exactly 5 presets', () => { + expect(getTimePresets()).toHaveLength(5) + }) + + it('each preset has id, label, start, end', () => { + getTimePresets().forEach(preset => { + expect(preset).toHaveProperty('id') + expect(preset).toHaveProperty('label') + expect(preset).toHaveProperty('start') + expect(preset).toHaveProperty('end') + }) + }) + + it('returns presets with the expected ids in order', () => { + const ids = getTimePresets().map(p => p.id) + expect(ids).toEqual(['today', 'last-7', 'last-30', 'this-year', 'last-year']) + }) + + it('each preset start is before its end', () => { + getTimePresets().forEach(preset => { + expect(preset.start).toBeLessThan(preset.end) + }) + }) + }) + + describe('business rule: today preset should span the current day', () => { + it('start is midnight of today', () => { + const preset = getTimePresets().find(p => p.id === 'today')! + const d = new Date(preset.start) + expect(d.getHours()).toBe(0) + expect(d.getMinutes()).toBe(0) + expect(d.getSeconds()).toBe(0) + }) + + it('end is 23:59:59.999 of today', () => { + const preset = getTimePresets().find(p => p.id === 'today')! + const d = new Date(preset.end) + expect(d.getHours()).toBe(23) + expect(d.getMinutes()).toBe(59) + expect(d.getSeconds()).toBe(59) + }) + }) + + describe('business rule: range widths should match their names', () => { + const MS_PER_DAY = 24 * 60 * 60 * 1000 + + it('last-7 spans approximately 7 days', () => { + const preset = getTimePresets().find(p => p.id === 'last-7')! + const days = (preset.end - preset.start) / MS_PER_DAY + expect(days).toBeGreaterThanOrEqual(7) + expect(days).toBeLessThan(8) + }) + + it('last-30 spans approximately 30 days', () => { + const preset = getTimePresets().find(p => p.id === 'last-30')! + const days = (preset.end - preset.start) / MS_PER_DAY + expect(days).toBeGreaterThanOrEqual(30) + expect(days).toBeLessThan(31) + }) + + it('this-year starts on January 1st', () => { + const preset = getTimePresets().find(p => p.id === 'this-year')! + const d = new Date(preset.start) + expect(d.getMonth()).toBe(0) + expect(d.getDate()).toBe(1) + expect(d.getFullYear()).toBe(new Date().getFullYear()) + }) + + it('last-year starts on January 1st of the previous year', () => { + const preset = getTimePresets().find(p => p.id === 'last-year')! + const d = new Date(preset.start) + expect(d.getMonth()).toBe(0) + expect(d.getDate()).toBe(1) + expect(d.getFullYear()).toBe(new Date().getFullYear() - 1) + }) + }) + + describe('business rule: dates are computed fresh on each call', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns different start for today when the date changes', () => { + vi.setSystemTime(new Date('2026-01-01T00:00:00')) + const presets1 = getTimePresets() + + vi.setSystemTime(new Date('2026-02-15T00:00:00')) + const presets2 = getTimePresets() + + expect(presets1[0].start).not.toBe(presets2[0].start) + }) + + it('always uses the fake current date, not a cached value', () => { + vi.setSystemTime(new Date('2026-06-15T12:00:00')) + const preset = getTimePresets().find(p => p.id === 'this-year')! + expect(new Date(preset.start).getFullYear()).toBe(2026) + }) + }) +}) + +describe('getTimePresetRange', () => { + describe('business rule: should return null for missing or unknown ids', () => { + it('returns null when presetId is empty string', () => { + expect(getTimePresetRange('')).toBeNull() + }) + + it('returns null when presetId is null/undefined', () => { + expect(getTimePresetRange(null as unknown as string)).toBeNull() + expect(getTimePresetRange(undefined as unknown as string)).toBeNull() + }) + + it('returns null for unknown preset id', () => { + expect(getTimePresetRange('unknown')).toBeNull() + expect(getTimePresetRange('weekly')).toBeNull() + }) + }) + + describe('business rule: should return { start, end } for known ids', () => { + it.each(['today', 'last-7', 'last-30', 'this-year', 'last-year'])('returns range for "%s"', (id) => { + const range = getTimePresetRange(id) + expect(range).not.toBeNull() + expect(range!.start).toBeTypeOf('number') + expect(range!.end).toBeTypeOf('number') + expect(range!.start).toBeLessThan(range!.end) + }) + + it('range values match getTimePresets output', () => { + const preset = getTimePresets().find(p => p.id === 'last-7')! + const range = getTimePresetRange('last-7')! + expect(range.start).toBe(preset.start) + expect(range.end).toBe(preset.end) + }) + }) +}) diff --git a/src/tests/views/FilesList/FileListFilter/FileListFilter.spec.ts b/src/tests/views/FilesList/FileListFilter/FileListFilter.spec.ts new file mode 100644 index 0000000000..f5f2c47584 --- /dev/null +++ b/src/tests/views/FilesList/FileListFilter/FileListFilter.spec.ts @@ -0,0 +1,112 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' + +import FileListFilter from '../../../../views/FilesList/FileListFilter/FileListFilter.vue' + +vi.mock('@nextcloud/l10n', () => ({ + t: vi.fn((_app: string, text: string) => text), +})) + +vi.mock('@nextcloud/logger', () => ({ + getLogger: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + getLoggerBuilder: vi.fn(() => ({ + setApp: vi.fn().mockReturnThis(), + detectUser: vi.fn().mockReturnThis(), + build: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + })), +})) + +vi.mock('@nextcloud/vue/components/NcButton', () => ({ + default: { + name: 'NcButton', + props: ['variant', 'alignment', 'wide'], + emits: ['click'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcPopover', () => ({ + default: { + name: 'NcPopover', + template: '
', + }, +})) + +describe('FileListFilter.vue', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ createSpy: vi.fn })) + }) + + function mountComponent(props: Record = {}) { + return mount(FileListFilter, { + props: { + isActive: false, + filterName: 'Test Filter', + ...props, + }, + slots: { + default: '
', + icon: '', + }, + }) + } + + /** Finds a button stub by its visible text label */ + function findButton(wrapper: ReturnType, label: string) { + return wrapper.findAll('.nc-button-stub').find((b) => b.text().includes(label)) + } + + it('renders filterName in the trigger button', () => { + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Test Filter') + }) + + it('uses tertiary variant on the trigger button when isActive is false', () => { + const wrapper = mountComponent({ isActive: false }) + expect(findButton(wrapper, 'Test Filter')?.attributes('data-variant')).toBe('tertiary') + }) + + it('uses secondary variant on the trigger button when isActive is true', () => { + const wrapper = mountComponent({ isActive: true }) + expect(findButton(wrapper, 'Test Filter')?.attributes('data-variant')).toBe('secondary') + }) + + it('does not render the Clear filter button when isActive is false', () => { + const wrapper = mountComponent({ isActive: false }) + expect(findButton(wrapper, 'Clear filter')).toBeUndefined() + }) + + it('renders the Clear filter button when isActive is true', () => { + const wrapper = mountComponent({ isActive: true }) + expect(findButton(wrapper, 'Clear filter')).toBeDefined() + }) + + it('renders the slot content inside the popover', () => { + const wrapper = mountComponent() + const popover = wrapper.find('.nc-popover-stub') + expect(popover.find('.filter-content-stub').exists()).toBe(true) + }) + + it('emits reset-filter when the Clear filter button is clicked', async () => { + const wrapper = mountComponent({ isActive: true }) + await findButton(wrapper, 'Clear filter')!.trigger('click') + expect(wrapper.emitted('reset-filter')).toHaveLength(1) + }) +}) diff --git a/src/tests/views/FilesList/FileListFilter/FileListFilterChips.spec.ts b/src/tests/views/FilesList/FileListFilter/FileListFilterChips.spec.ts new file mode 100644 index 0000000000..969368fc29 --- /dev/null +++ b/src/tests/views/FilesList/FileListFilter/FileListFilterChips.spec.ts @@ -0,0 +1,159 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' + +import FileListFilterChips from '../../../../views/FilesList/FileListFilter/FileListFilterChips.vue' +import { useFiltersStore } from '../../../../store/filters.js' + +vi.mock('@nextcloud/l10n', () => ({ + t: vi.fn((_app: string, text: string) => text), +})) + +vi.mock('@nextcloud/logger', () => ({ + getLogger: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + getLoggerBuilder: vi.fn(() => ({ + setApp: vi.fn().mockReturnThis(), + detectUser: vi.fn().mockReturnThis(), + build: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + })), +})) + +vi.mock('@nextcloud/axios', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn() }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path: string) => `/ocs/v2.php${path}`), +})) + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn((_app: string, _key: string, defaultValue: unknown) => defaultValue), +})) + +vi.mock('@nextcloud/event-bus', () => ({ + emit: vi.fn(), + subscribe: vi.fn(), +})) + +vi.mock('@nextcloud/vue/components/NcAvatar', () => ({ + default: { + name: 'NcAvatar', + props: ['user', 'size', 'disableMenu', 'verboseStatus'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcChip', () => ({ + default: { + name: 'NcChip', + props: ['text', 'iconSvg', 'ariaLabelClose'], + emits: ['close'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcIconSvgWrapper', () => ({ + default: { + name: 'NcIconSvgWrapper', + props: ['path', 'svg', 'size'], + template: '', + }, +})) + +vi.mock('../../../../views/FilesList/FileListFilter/FileListFilter.vue', () => ({ + default: { + name: 'FileListFilter', + props: ['isActive', 'filterName'], + emits: ['reset-filter'], + template: '
', + }, +})) + +describe('FileListFilterModified.vue', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ createSpy: vi.fn })) + }) + + function mountComponent() { + return mount(FileListFilterModified) + } + + /** Finds a preset option button by its visible label */ + function findPresetButton(wrapper: ReturnType, label: string) { + return wrapper.findAll('.nc-button-stub').find((b) => b.text() === label) + } + + it('selectedOption is null when filter_modified is not set', () => { + const wrapper = mountComponent() + expect(wrapper.vm.selectedOption).toBeNull() + }) + + it('isActive is false when no preset is selected', () => { + const wrapper = mountComponent() + expect(wrapper.vm.isActive).toBe(false) + }) + + it('passes isActive=false to FileListFilter when no preset selected', () => { + const wrapper = mountComponent() + expect(wrapper.find('.file-list-filter-stub').attributes('data-is-active')).toBe('false') + }) + + it('renders 5 preset option buttons', () => { + const wrapper = mountComponent() + const buttons = wrapper.findAll('.nc-button-stub') + expect(buttons).toHaveLength(5) + }) + + it('clicking a preset button sets selectedOption to that preset id', async () => { + const wrapper = mountComponent() + await findPresetButton(wrapper, 'Today')!.trigger('click') + expect(wrapper.vm.selectedOption).toBe('today') + }) + + it('isActive becomes true after a preset is selected', async () => { + const wrapper = mountComponent() + await findPresetButton(wrapper, 'Today')!.trigger('click') + expect(wrapper.vm.isActive).toBe(true) + }) + + it('selected button reports pressed=true', async () => { + const wrapper = mountComponent() + const todayButton = findPresetButton(wrapper, 'Today')! + await todayButton.trigger('click') + expect(todayButton.attributes('data-pressed')).toBe('true') + }) + + it('clicking the same preset again deselects it (radio toggle)', async () => { + const wrapper = mountComponent() + const todayButton = findPresetButton(wrapper, 'Today')! + await todayButton.trigger('click') + expect(wrapper.vm.selectedOption).toBe('today') + await todayButton.trigger('click') + expect(wrapper.vm.selectedOption).toBeNull() + }) + + it('isActive goes back to false after deselecting', async () => { + const wrapper = mountComponent() + const todayButton = findPresetButton(wrapper, 'Today')! + await todayButton.trigger('click') + await todayButton.trigger('click') + expect(wrapper.vm.isActive).toBe(false) + }) + + it('initialises selectedOption from filtersStore.filter_modified when set', () => { + const filtersStore = useFiltersStore() + filtersStore.$patch({ filter_modified: 'last-7' }) + + const wrapper = mountComponent() + expect(wrapper.vm.selectedOption).toBe('last-7') + }) + + it('resetFilter clears selectedOption when it is set', async () => { + const wrapper = mountComponent() + await findPresetButton(wrapper, 'Last 7 days')!.trigger('click') + expect(wrapper.vm.selectedOption).toBe('last-7') + + wrapper.vm.resetFilter() + await wrapper.vm.$nextTick() + + expect(wrapper.vm.selectedOption).toBeNull() + }) + + // createTestingPinia auto-spies all store actions — no vi.spyOn needed + it('watch triggers onFilterUpdateChips when selectedOption changes', async () => { + const filtersStore = useFiltersStore() + const wrapper = mountComponent() + await findPresetButton(wrapper, 'Today')!.trigger('click') + expect(filtersStore.onFilterUpdateChips).toHaveBeenCalled() + }) +}) diff --git a/src/tests/views/FilesList/FileListFilter/FileListFilterStatus.spec.ts b/src/tests/views/FilesList/FileListFilter/FileListFilterStatus.spec.ts new file mode 100644 index 0000000000..cef52af4a4 --- /dev/null +++ b/src/tests/views/FilesList/FileListFilter/FileListFilterStatus.spec.ts @@ -0,0 +1,189 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' + +import FileListFilterStatus from '../../../../views/FilesList/FileListFilter/FileListFilterStatus.vue' +import { useFiltersStore } from '../../../../store/filters.js' +import { FILE_STATUS } from '../../../../constants.js' + +vi.mock('@nextcloud/l10n', () => ({ + t: vi.fn((_app: string, text: string) => text), +})) + +vi.mock('@nextcloud/logger', () => ({ + getLogger: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + getLoggerBuilder: vi.fn(() => ({ + setApp: vi.fn().mockReturnThis(), + detectUser: vi.fn().mockReturnThis(), + build: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + })), +})) + +vi.mock('@nextcloud/axios', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn() }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path: string) => `/ocs/v2.php${path}`), +})) + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn((_app: string, _key: string, defaultValue: unknown) => defaultValue), +})) + +vi.mock('@nextcloud/event-bus', () => ({ + emit: vi.fn(), + subscribe: vi.fn(), +})) + +vi.mock('@nextcloud/vue/components/NcButton', () => ({ + default: { + name: 'NcButton', + props: ['variant', 'alignment', 'wide', 'pressed'], + emits: ['click'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcIconSvgWrapper', () => ({ + default: { + name: 'NcIconSvgWrapper', + props: ['path', 'svg', 'size'], + template: '', + }, +})) + +vi.mock('../../../../views/FilesList/FileListFilter/FileListFilter.vue', () => ({ + default: { + name: 'FileListFilter', + props: ['isActive', 'filterName'], + emits: ['reset-filter'], + template: '
', + }, +})) + +describe('FileListFilterStatus.vue', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ createSpy: vi.fn })) + }) + + function mountComponent() { + return mount(FileListFilterStatus) + } + + /** Finds a status option button by its visible label */ + function findStatusButton(wrapper: ReturnType, label: string) { + return wrapper.findAll('.nc-button-stub').find((b) => b.text().includes(label)) + } + + it('renders one button for each file status option', () => { + const wrapper = mountComponent() + // DRAFT, ABLE_TO_SIGN, PARTIAL_SIGNED, SIGNED = 4 + expect(wrapper.findAll('.nc-button-stub')).toHaveLength(4) + }) + + it('isActive is false when no options are selected', () => { + const wrapper = mountComponent() + expect(wrapper.vm.isActive).toBe(false) + }) + + it('passes isActive=false to FileListFilter when nothing selected', () => { + const wrapper = mountComponent() + expect(wrapper.find('.file-list-filter-stub').attributes('data-is-active')).toBe('false') + }) + + it('all buttons are unpressed when no options are selected', () => { + const wrapper = mountComponent() + wrapper.findAll('.nc-button-stub').forEach(button => { + expect(button.attributes('data-pressed')).toBe('false') + }) + }) + + it('clicking a status button adds it to selectedOptions', async () => { + const wrapper = mountComponent() + await findStatusButton(wrapper, 'Draft')!.trigger('click') + expect(wrapper.vm.selectedOptions).toContain(FILE_STATUS.DRAFT) + }) + + it('isActive becomes true after a status is selected', async () => { + const wrapper = mountComponent() + await findStatusButton(wrapper, 'Draft')!.trigger('click') + expect(wrapper.vm.isActive).toBe(true) + }) + + it('clicked button becomes pressed', async () => { + const wrapper = mountComponent() + const draftButton = findStatusButton(wrapper, 'Draft')! + await draftButton.trigger('click') + expect(draftButton.attributes('data-pressed')).toBe('true') + }) + + it('clicking a second different status adds it too (multi-select)', async () => { + const wrapper = mountComponent() + await findStatusButton(wrapper, 'Draft')!.trigger('click') + await findStatusButton(wrapper, 'Ready to sign')!.trigger('click') + expect(wrapper.vm.selectedOptions).toContain(FILE_STATUS.DRAFT) + expect(wrapper.vm.selectedOptions).toContain(FILE_STATUS.ABLE_TO_SIGN) + }) + + it('clicking an already-selected option removes it (toggle)', async () => { + const wrapper = mountComponent() + const draftButton = findStatusButton(wrapper, 'Draft')! + await draftButton.trigger('click') // select + expect(wrapper.vm.selectedOptions).toContain(FILE_STATUS.DRAFT) + await draftButton.trigger('click') // deselect + expect(wrapper.vm.selectedOptions).not.toContain(FILE_STATUS.DRAFT) + }) + + it('isActive goes back to false when all selections are removed', async () => { + const wrapper = mountComponent() + const draftButton = findStatusButton(wrapper, 'Draft')! + await draftButton.trigger('click') + await draftButton.trigger('click') + expect(wrapper.vm.isActive).toBe(false) + }) + + it('initialises selectedOptions from filtersStore.filterStatusArray', () => { + const filtersStore = useFiltersStore() + filtersStore.$patch({ filter_status: `[${FILE_STATUS.SIGNED}]` }) + + const wrapper = mountComponent() + expect(wrapper.vm.selectedOptions).toContain(FILE_STATUS.SIGNED) + }) + + it('resetFilter clears all selectedOptions', async () => { + const wrapper = mountComponent() + await findStatusButton(wrapper, 'Draft')!.trigger('click') + await findStatusButton(wrapper, 'Ready to sign')!.trigger('click') + expect(wrapper.vm.selectedOptions).toHaveLength(2) + + wrapper.vm.resetFilter() + await wrapper.vm.$nextTick() + + expect(wrapper.vm.selectedOptions).toHaveLength(0) + }) + + // createTestingPinia auto-spies all store actions — no vi.spyOn needed + it('watch calls onFilterUpdateChipsAndSave on the store when selectedOptions changes', async () => { + const filtersStore = useFiltersStore() + const wrapper = mountComponent() + await findStatusButton(wrapper, 'Draft')!.trigger('click') + expect(filtersStore.onFilterUpdateChipsAndSave).toHaveBeenCalled() + }) +}) diff --git a/src/tests/views/FilesList/FileListFilters.spec.ts b/src/tests/views/FilesList/FileListFilters.spec.ts new file mode 100644 index 0000000000..c750fefb52 --- /dev/null +++ b/src/tests/views/FilesList/FileListFilters.spec.ts @@ -0,0 +1,208 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { computed, ref } from '@vue/reactivity' +import { setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' + +import FileListFilters from '../../../views/FilesList/FileListFilters.vue' +import { useFiltersStore } from '../../../store/filters.js' + +// Controlled ref to toggle isWide in tests +const mockIsWide = ref(true) + +vi.mock('../../../composables/useFileListWidth.js', () => ({ + useFileListWidth: () => ({ + isWide: computed(() => mockIsWide.value), + isMedium: computed(() => false), + isNarrow: computed(() => !mockIsWide.value), + width: ref(0), + }), +})) + +vi.mock('@nextcloud/l10n', () => ({ + t: vi.fn((_app: string, text: string) => text), +})) + +vi.mock('@nextcloud/logger', () => ({ + getLogger: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + getLoggerBuilder: vi.fn(() => ({ + setApp: vi.fn().mockReturnThis(), + detectUser: vi.fn().mockReturnThis(), + build: vi.fn(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + })), + })), +})) + +vi.mock('@nextcloud/axios', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path: string) => `/ocs/v2.php${path}`), +})) + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn((_app: string, _key: string, defaultValue: unknown) => defaultValue), +})) + +vi.mock('@nextcloud/moment', () => ({ + default: vi.fn(() => ({ + format: () => 'date', + fromNow: () => '2 days ago', + })), +})) + +vi.mock('@nextcloud/vue/components/NcButton', () => ({ + default: { + name: 'NcButton', + props: ['pressed', 'variant', 'ariaLabel'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcIconSvgWrapper', () => ({ + default: { + name: 'NcIconSvgWrapper', + props: ['path', 'svg', 'size'], + template: '', + }, +})) + +vi.mock('@nextcloud/vue/components/NcPopover', () => ({ + default: { + name: 'NcPopover', + template: '
', + }, +})) + +vi.mock('../../../views/FilesList/FileListFilter/FileListFilterModified.vue', () => ({ + default: { + name: 'FileListFilterModified', + template: '
', + }, +})) + +vi.mock('../../../views/FilesList/FileListFilter/FileListFilterStatus.vue', () => ({ + default: { + name: 'FileListFilterStatus', + template: '
', + }, +})) + +describe('FileListFilters.vue', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ createSpy: vi.fn })) + vi.clearAllMocks() + mockIsWide.value = true + }) + + function mountComponent() { + return mount(FileListFilters) + } + + it('has data-test-id="files-list-filters"', () => { + const wrapper = mountComponent() + expect(wrapper.find('[data-test-id="files-list-filters"]').exists()).toBe(true) + }) + + describe('wide layout (isWide = true)', () => { + beforeEach(() => { + mockIsWide.value = true + }) + + it('renders FileListFilterModified directly in the header', () => { + const wrapper = mountComponent() + expect(wrapper.find('.file-list-filter-modified-stub').exists()).toBe(true) + }) + + it('renders FileListFilterStatus directly in the header', () => { + const wrapper = mountComponent() + expect(wrapper.find('.file-list-filter-status-stub').exists()).toBe(true) + }) + + it('does not render the collapsed filter button', () => { + const wrapper = mountComponent() + expect(wrapper.find('.nc-popover-stub').exists()).toBe(false) + }) + }) + + describe('narrow layout (isWide = false)', () => { + beforeEach(() => { + mockIsWide.value = false + }) + + it('renders NcPopover as the collapsed filter trigger', () => { + const wrapper = mountComponent() + expect(wrapper.find('.nc-popover-stub').exists()).toBe(true) + }) + + it('renders the filter icon button inside the popover trigger', () => { + const wrapper = mountComponent() + const icon = wrapper.find('.nc-icon') + expect(icon.exists()).toBe(true) + }) + + it('the filter button shows mdiFilterVariant icon', () => { + const wrapper = mountComponent() + const icon = wrapper.find('.nc-icon') + expect(icon.attributes('data-path')).toBe(wrapper.vm.mdiFilterVariant) + }) + + it('renders individual filter components inside the popover content', () => { + const wrapper = mountComponent() + expect(wrapper.find('.file-list-filter-modified-stub').exists()).toBe(true) + expect(wrapper.find('.file-list-filter-status-stub').exists()).toBe(true) + }) + + it('does not render individual filters outside of the popover', () => { + const wrapper = mountComponent() + // All filter stubs should be inside the popover + const popover = wrapper.find('.nc-popover-stub') + expect(popover.find('.file-list-filter-modified-stub').exists()).toBe(true) + expect(popover.find('.file-list-filter-status-stub').exists()).toBe(true) + }) + }) + + describe('hasActiveFilters (button pressed state)', () => { + beforeEach(() => { + mockIsWide.value = false + }) + + it('button is not pressed when there are no active chips', () => { + const filtersStore = useFiltersStore() + filtersStore.$patch({ chips: {} }) + + const wrapper = mountComponent() + const button = wrapper.find('.nc-button-stub') + expect(button.attributes('data-pressed')).toBe('false') + }) + + it('button is pressed when there are active chips', async () => { + const filtersStore = useFiltersStore() + filtersStore.$patch({ chips: { test: [{ text: 'Modified', icon: '', onclick: () => {} }] } }) + + const wrapper = mountComponent() + const button = wrapper.find('.nc-button-stub') + expect(button.attributes('data-pressed')).toBe('true') + }) + }) +}) diff --git a/src/tests/views/FilesList/FilesList.spec.ts b/src/tests/views/FilesList/FilesList.spec.ts index 0703bf671a..347235f248 100644 --- a/src/tests/views/FilesList/FilesList.spec.ts +++ b/src/tests/views/FilesList/FilesList.spec.ts @@ -104,6 +104,13 @@ vi.mock('../../../views/FilesList/FilesListVirtual.vue', () => ({ }, })) +vi.mock('../../../views/FilesList/FileListFilters.vue', () => ({ + default: { + name: 'FileListFilters', + template: '
', + }, +})) + vi.mock('../../../components/Request/RequestPicker.vue', () => ({ default: { name: 'RequestPicker', diff --git a/src/utils/timePresets.js b/src/utils/timePresets.js new file mode 100644 index 0000000000..129a40a76e --- /dev/null +++ b/src/utils/timePresets.js @@ -0,0 +1,68 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' + +const startOfToday = () => (new Date()).setHours(0, 0, 0, 0) +const endOfToday = () => (new Date()).setHours(23, 59, 59, 999) + +/** + * Returns the list of time preset definitions (id, label, start, end). + * Called as a function so that dates are always computed at the moment of use. + * + * @return {Array<{id: string, label: string, start: number, end: number}>} + */ +export function getTimePresets() { + const today = startOfToday() + const todayEnd = endOfToday() + + return [ + { + id: 'today', + label: t('libresign', 'Today'), + start: today, + end: todayEnd, + }, + { + id: 'last-7', + label: t('libresign', 'Last 7 days'), + start: today - (7 * 24 * 60 * 60 * 1000), + end: todayEnd, + }, + { + id: 'last-30', + label: t('libresign', 'Last 30 days'), + start: today - (30 * 24 * 60 * 60 * 1000), + end: todayEnd, + }, + { + id: 'this-year', + label: t('libresign', 'This year ({year})', { year: (new Date()).getFullYear() }), + start: new Date(today).setMonth(0, 1), + end: todayEnd, + }, + { + id: 'last-year', + label: t('libresign', 'Last year ({year})', { year: (new Date()).getFullYear() - 1 }), + start: new Date(today).setFullYear(new Date().getFullYear() - 1, 0, 1), + end: new Date(today).setMonth(0, 1), + }, + ] +} + +/** + * Returns the { start, end } range in milliseconds for the given preset id, + * or null if the id is not recognised. + * + * @param {string} presetId + * @return {{ start: number, end: number } | null} + */ +export function getTimePresetRange(presetId) { + if (!presetId) { + return null + } + const preset = getTimePresets().find(p => p.id === presetId) + return preset ? { start: preset.start, end: preset.end } : null +} diff --git a/src/views/FilesList/FileListFilter/FileListFilter.vue b/src/views/FilesList/FileListFilter/FileListFilter.vue index 272aafdc63..a5cea01cef 100644 --- a/src/views/FilesList/FileListFilter/FileListFilter.vue +++ b/src/views/FilesList/FileListFilter/FileListFilter.vue @@ -3,38 +3,44 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> @@ -28,7 +30,7 @@ import { t } from '@nextcloud/l10n' import { mdiListStatus } from '@mdi/js' -import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcButton from '@nextcloud/vue/components/NcButton' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import FileListFilter from './FileListFilter.vue' @@ -40,7 +42,7 @@ import { useFiltersStore } from '../../../store/filters.js' export default { name: 'FileListFilterStatus', components: { - NcActionButton, + NcButton, NcIconSvgWrapper, FileListFilter, }, @@ -111,9 +113,9 @@ export default { toggleOption(option) { const idx = this.selectedOptions.indexOf(option) if (idx !== -1) { - this.selectedOptions.splice(idx, 1) + this.selectedOptions = this.selectedOptions.filter(v => v !== option) } else { - this.selectedOptions.push(option) + this.selectedOptions = [...this.selectedOptions, option] } }, setMarkedFilter() { diff --git a/src/views/FilesList/FileListFilters.vue b/src/views/FilesList/FileListFilters.vue index abb23b10af..f346dbeac3 100644 --- a/src/views/FilesList/FileListFilters.vue +++ b/src/views/FilesList/FileListFilters.vue @@ -3,55 +3,71 @@ - SPDX-License-Identifier: AGPL-3.0-or-later -->