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
9c27358
feat(helpers): add shared reactive initialActionCode ref
vitormattos Feb 26, 2026
67f0398
feat(composables): add useFileListWidth composable
vitormattos Feb 26, 2026
bc29052
feat(utils): add timePresets utility with getTimePresets and getTimeP…
vitormattos Feb 26, 2026
b2828bf
refactor(FileListFilter): replace NcActions with NcPopover-based layout
vitormattos Feb 26, 2026
1afd9c6
feat(FileListFilterChips): add dedicated active filter chips component
vitormattos Feb 26, 2026
14100b9
refactor(FileListFilters): split filter buttons and active chips into…
vitormattos Feb 26, 2026
1ec1ea2
refactor(FileListFilterModified): use shared timePresets utility
vitormattos Feb 26, 2026
83204bf
refactor(FileListFilterStatus): replace NcActionButton with NcButton
vitormattos Feb 26, 2026
a8c87e2
feat(FilesList): add FileListFilters to file list header
vitormattos Feb 26, 2026
551c950
fix(FilesListVirtual): fix empty space when no active filters; use Fi…
vitormattos Feb 26, 2026
a981b68
feat(store/filters): add filterModifiedRange getter; persist filter_m…
vitormattos Feb 26, 2026
2237649
fix(store/files): restore saved filters on page load using persisted …
vitormattos Feb 26, 2026
84c849f
test(FileListFilter): add component tests
vitormattos Feb 26, 2026
38ac0a4
test(FileListFilterChips): add component tests
vitormattos Feb 26, 2026
86c6551
test(FileListFilterModified): add component tests
vitormattos Feb 26, 2026
f6610b4
test(FileListFilterStatus): add component tests
vitormattos Feb 26, 2026
8cc68a1
test(FileListFilters): add component tests
vitormattos Feb 26, 2026
527b38c
test(timePresets): add utility tests
vitormattos Feb 26, 2026
0c94cfa
test(store/filters): add filterModifiedRange getter and filter_modifi…
vitormattos Feb 26, 2026
6137071
test(store/files): update filters mock to match new store interface
vitormattos Feb 26, 2026
82f05b6
fix(test/Signer): use vi.hoisted for translation helpers to fix mock …
vitormattos Feb 26, 2026
98552b6
test(FilesList): add test for FileListFilters placement in header
vitormattos Feb 26, 2026
5387f3e
fix(store/filters): remove premature emit from onFilterUpdateChips
vitormattos Feb 26, 2026
60e9806
fix(store/filters): use files_list_filter_* keys to match PHP config
vitormattos Feb 26, 2026
345d25f
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,
}
}
10 changes: 10 additions & 0 deletions src/helpers/ActionMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { ref } from '@vue/reactivity'

interface ActionCodes {
REDIRECT: number
CREATE_ACCOUNT: number
Expand Down Expand Up @@ -53,6 +55,14 @@ export const ACTION_CODE_TO_ROUTE: Readonly<ActionCodeToRoute> = Object.freeze({
[ACTION_CODES.INCOMPLETE_SETUP]: 'Incomplete',
})

/**
* Shared reactive ref for the initial action code injected by the server
* (#initial-state-libresign-action). Written once by router.ts beforeEach,
* read by App.vue. Lives here (not in router.ts) to avoid App.vue triggering
* the router module's side effects (createRouter, generateUrl) on import.
*/
export const initialActionCode = ref(0)

export const REQUIREMENT_TO_MODAL: Readonly<RequirementToModal> = Object.freeze({
identificationDocuments: 'uploadDocuments',
emailCode: 'emailToken',
Expand Down
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