Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
163fa42
feat(helpers): add shared reactive initialActionCode ref
vitormattos Feb 26, 2026
c296a49
feat(composables): add useFileListWidth composable
vitormattos Feb 26, 2026
b4e9be8
feat(utils): add timePresets utility with getTimePresets and getTimeP…
vitormattos Feb 26, 2026
691704e
refactor(FileListFilter): replace NcActions with NcPopover-based layout
vitormattos Feb 26, 2026
422e814
feat(FileListFilterChips): add dedicated active filter chips component
vitormattos Feb 26, 2026
bee6753
refactor(FileListFilters): split filter buttons and active chips into…
vitormattos Feb 26, 2026
6a1710e
refactor(FileListFilterModified): use shared timePresets utility
vitormattos Feb 26, 2026
2817eaf
refactor(FileListFilterStatus): replace NcActionButton with NcButton
vitormattos Feb 26, 2026
537a941
feat(FilesList): add FileListFilters to file list header
vitormattos Feb 26, 2026
977e1f6
fix(FilesListVirtual): fix empty space when no active filters; use Fi…
vitormattos Feb 26, 2026
cbc325a
feat(store/filters): add filterModifiedRange getter; persist filter_m…
vitormattos Feb 26, 2026
f5b0b9f
fix(store/files): restore saved filters on page load using persisted …
vitormattos Feb 26, 2026
6b9f7a5
test(FileListFilter): add component tests
vitormattos Feb 26, 2026
49c5809
test(FileListFilterChips): add component tests
vitormattos Feb 26, 2026
a4f7c83
test(FileListFilterModified): add component tests
vitormattos Feb 26, 2026
24ac9d0
test(FileListFilterStatus): add component tests
vitormattos Feb 26, 2026
f1dab12
test(FileListFilters): add component tests
vitormattos Feb 26, 2026
5fbbef3
test(timePresets): add utility tests
vitormattos Feb 26, 2026
1f97250
test(store/filters): add filterModifiedRange getter and filter_modifi…
vitormattos Feb 26, 2026
a2b1298
test(store/files): update filters mock to match new store interface
vitormattos Feb 26, 2026
a07faad
fix(test/Signer): use vi.hoisted for translation helpers to fix mock …
vitormattos Feb 26, 2026
6a66de3
test(FilesList): add test for FileListFilters placement in header
vitormattos Feb 26, 2026
bbc02ca
fix(store/filters): remove premature emit from onFilterUpdateChips
vitormattos Feb 26, 2026
2ad1c3b
fix(store/filters): use files_list_filter_* keys to match PHP config
vitormattos Feb 26, 2026
0f780c3
test(store/filters): add initialisation tests for files_list_filter_*…
vitormattos Feb 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/composables/useFileListWidth.ts
Original file line number Diff line number Diff line change
@@ -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<number>(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<HTMLElement>('#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,
}
}
18 changes: 8 additions & 10 deletions src/store/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 15 additions & 5 deletions src/store/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 })

},
Expand All @@ -47,18 +55,20 @@ 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')
}

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

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,
})

Expand Down
16 changes: 10 additions & 6 deletions src/tests/components/Signers/Signer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,18 @@ type FilesStoreMock = ReturnType<typeof useFilesStore> & {
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

Expand Down
7 changes: 6 additions & 1 deletion src/tests/store/files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
123 changes: 115 additions & 8 deletions src/tests/store/filters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loadState>
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<typeof loadState>
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<typeof loadState>
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()
Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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' }
)
})
Expand All @@ -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' }
)
})
Expand All @@ -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', () => {
Expand All @@ -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"]' }
)
})
Expand All @@ -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: '' }
)
})
Expand Down
Loading
Loading