diff --git a/playwright/e2e/delete-pending-request.spec.ts b/playwright/e2e/delete-pending-request.spec.ts new file mode 100644 index 0000000000..90ad529562 --- /dev/null +++ b/playwright/e2e/delete-pending-request.spec.ts @@ -0,0 +1,87 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import { login } from '../support/nc-login' +import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' + +test('delete pending signature request', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + page.request, + 'libresign', + 'identify_methods', + JSON.stringify([ + { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, mandatory: false }, + ]), + ) + + await page.goto('./apps/libresign') + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + await page.getByRole('button', { name: 'Add signer' }).click() + await page.getByPlaceholder('Account').fill('a') + await page.getByRole('option', { name: 'admin@email.tld' }).click() + await page.getByRole('button', { name: 'Save' }).click() + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + + // Navigate to the Files list and ensure it is sorted by Created at, newest first + await page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() + const createdAtTh = page.getByRole('columnheader', { name: 'Created at' }) + const sortDirection = await createdAtTh.evaluate((el: HTMLElement) => el.ariaSort) + if (sortDirection !== 'descending') { + await page.getByRole('button', { name: 'Created at' }).click() + if (sortDirection === 'none') { + // Column was sortable but not active: first click set it to ascending, one more for descending + await page.getByRole('button', { name: 'Created at' }).click() + } + } + + // The most recently uploaded document is first — rename it to a unique name + // so it can be unambiguously identified regardless of other documents in the list. + // NcActionButton inside NcActions renders as role="menuitem", not role="button". + const uniqueName = `delete-pending-test-${Date.now()}` + const firstRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') + .filter({ hasText: 'small_valid' }) + .first() + await firstRow.getByRole('button', { name: 'Actions' }).click() + await page.getByRole('menuitem', { name: 'Rename' }).click() + await page.getByLabel('File name').fill(uniqueName) + await page.getByLabel('File name').press('Enter') + + // Find the row by its unique name and assert the status + const targetRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row') + .filter({ hasText: uniqueName }) + await expect(targetRow.locator('.status-chip__text')).toHaveText('Ready to sign') + + // Delete it + await targetRow.getByRole('button', { name: 'Actions' }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + + // Confirm the deletion in the dialog + await expect(page.getByRole('dialog', { name: 'Confirm' })).toBeVisible() + await expect(page.getByText('The signature request will be deleted. Do you confirm this action?')).toBeVisible() + await page.getByRole('button', { name: 'Ok' }).click() + + // The specific row we deleted must disappear from the list + await expect(targetRow).toBeHidden() +}) + diff --git a/playwright/e2e/visible-element-persistence.spec.ts b/playwright/e2e/visible-element-persistence.spec.ts new file mode 100644 index 0000000000..03a3381643 --- /dev/null +++ b/playwright/e2e/visible-element-persistence.spec.ts @@ -0,0 +1,128 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import { login } from '../support/nc-login' +import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' + +test('visible signature element persists and can be deleted', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + page.request, + 'libresign', + 'identify_methods', + JSON.stringify([ + { name: 'account', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } } }, + { name: 'email', enabled: false, mandatory: false }, + ]), + ) + + await page.goto('./apps/libresign') + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + await page.getByRole('button', { name: 'Add signer' }).click() + await page.getByPlaceholder('Account').fill('a') + await page.getByRole('option', { name: 'admin@email.tld' }).click() + await page.getByRole('textbox', { name: 'Signer name' }).click() + await page.getByRole('textbox', { name: 'Signer name' }).press('ControlOrMeta+a') + await page.getByRole('textbox', { name: 'Signer name' }).fill('Admin Name') + + // Save the signer first, then open the signature positions modal + await page.getByRole('button', { name: 'Save' }).click() + await page.getByRole('button', { name: 'Setup signature positions' }).click() + await expect(page.getByLabel('Page 1 of 1.')).toBeVisible() + + // Select the signer to enter element-placement mode + await page.getByLabel('Signature positions').getByRole('link', { name: 'Edit signer Admin Name' }).click() + await expect(page.getByText('Click on the place you want to add.')).toBeVisible() + + // Placing a signature element requires three steps: + // 1. hover() triggers handleMouseMove, setting previewVisible=true inside a rAF callback. + // 2. Waiting for .preview-element confirms the rAF ran. + // 3. click() fires mouseup, which calls finishAdding() and places the element. + const overlay = page.getByLabel('Page 1 of 1. Press Enter or Space to place the signature here.') + await overlay.hover() + await page.getByLabel('Signature positions').locator('.preview-element').first().waitFor({ state: 'visible' }) + await overlay.click() + + await expect( + page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), + ).toBeVisible() + + // Save closes the modal and persists the element via API + await page.getByLabel('Signature positions').getByRole('button', { name: 'Save' }).click() + + // Navigate to the Files list and ensure it is sorted by Created at, newest first (descending) + await page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() + const createdAtTh = page.locator('th.files-list__row-created_at') + const sortDirection = await createdAtTh.getAttribute('aria-sort') + if (sortDirection !== 'descending') { + await page.getByRole('button', { name: 'Created at' }).click() + if (sortDirection === 'none') { + // Column was sortable but not active: first click set it to ascending, one more for descending + await page.getByRole('button', { name: 'Created at' }).click() + } + } + const firstRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row').first() + await expect(firstRow.getByRole('button', { name: 'small_valid' })).toBeVisible() + + // Re-open the document by clicking the file name — the sidebar opens automatically + await firstRow.getByRole('button', { name: 'small_valid' }).click() + await page.getByRole('button', { name: 'Setup signature positions' }).click() + + // Verify the element survived the round-trip to the server + await expect( + page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), + ).toBeVisible() + + // Select the element so the toolbar (Duplicate / Delete) appears, then delete it + await page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }).click() + await page.getByLabel('Signature positions').getByRole('button', { name: 'Delete' }).click() + + await expect( + page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), + ).toBeHidden() + + // Save the now-empty element list + await page.getByLabel('Signature positions').getByRole('button', { name: 'Save' }).click() + + // Navigate away and back to force a fresh load from the server + await page.getByRole('link', { name: 'Request' }).click() + await page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() + const createdAtTh2 = page.getByRole('columnheader', { name: 'Created at' }) + const sortDirection2 = await createdAtTh2.evaluate((el: HTMLElement) => el.ariaSort) + if (sortDirection2 !== 'descending') { + await page.getByRole('button', { name: 'Created at' }).click() + if (sortDirection2 === 'none') { + // Column was sortable but not active: first click set it to ascending, one more for descending + await page.getByRole('button', { name: 'Created at' }).click() + } + } + const lastRow = page.locator('[data-cy-files-list-tbody] tr.files-list__row').first() + await expect(lastRow.getByRole('button', { name: 'small_valid' })).toBeVisible() + + // Re-open the document one last time and confirm the element is gone + await lastRow.getByRole('button', { name: 'small_valid' }).click() + await page.getByRole('button', { name: 'Setup signature positions' }).click() + await expect(page.getByLabel('Page 1 of 1.')).toBeVisible() + + await expect( + page.getByLabel('Signature positions').getByRole('img', { name: 'Signature position for Admin Name' }), + ).toBeHidden() +}) diff --git a/src/views/FilesList/FilesListTableHeader.spec.ts b/src/views/FilesList/FilesListTableHeader.spec.ts index 4bc18d4d84..f366bdbd88 100644 --- a/src/views/FilesList/FilesListTableHeader.spec.ts +++ b/src/views/FilesList/FilesListTableHeader.spec.ts @@ -15,6 +15,7 @@ import { mount } from '@vue/test-utils' import { setActivePinia, createPinia } from 'pinia' import FilesListTableHeader from './FilesListTableHeader.vue' import { useFilesStore } from '../../store/files.js' +import { useFilesSortingStore } from '../../store/filesSorting.js' // --------------------------------------------------------------------------- // Mocks @@ -219,4 +220,80 @@ describe('FilesListTableHeader', () => { expect(stub.props('modelValue')).toBe(false) }) }) + + describe('RULE: ariaSortForMode reflects filesSortingStore state', () => { + // loadState mock returns the default value, so the store initialises with + // sortingMode = 'created_at' and sortingDirection = 'desc'. + + it('returns "none" when the column is sortable but not the active sort mode', () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType & { ariaSortForMode: (mode: string, isSortable?: boolean) => string | null } + + expect(vm.ariaSortForMode('status')).toBe('none') + expect(vm.ariaSortForMode('name')).toBe('none') + }) + + it('returns null when the column is not sortable', () => { + const wrapper = createWrapper() + const vm = wrapper.vm as InstanceType & { ariaSortForMode: (mode: string, isSortable?: boolean) => string | null } + + expect(vm.ariaSortForMode('actions', false)).toBeNull() + }) + + it('returns "descending" when the column is active and direction is desc', async () => { + const wrapper = createWrapper() + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + sortingStore.sortingDirection = 'desc' + await wrapper.vm.$nextTick() + + const vm = wrapper.vm as InstanceType & { ariaSortForMode: (mode: string) => string | null } + expect(vm.ariaSortForMode('created_at')).toBe('descending') + }) + + it('returns "ascending" when the column is active and direction is asc', async () => { + const wrapper = createWrapper() + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'status' + sortingStore.sortingDirection = 'asc' + await wrapper.vm.$nextTick() + + const vm = wrapper.vm as InstanceType & { ariaSortForMode: (mode: string) => string | null } + expect(vm.ariaSortForMode('status')).toBe('ascending') + }) + + it('sets aria-sort attribute on the active element', async () => { + const wrapper = createWrapper() + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + sortingStore.sortingDirection = 'desc' + await wrapper.vm.$nextTick() + + const th = wrapper.find('th.files-list__row-created_at') + expect(th.attributes('aria-sort')).toBe('descending') + }) + + it('does not set aria-sort on non-sortable columns', async () => { + const wrapper = createWrapper() + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + sortingStore.sortingDirection = 'desc' + await wrapper.vm.$nextTick() + + // The actions column has no aria-sort (not sortable) + const actionsTh = wrapper.find('th.files-list__row-actions') + expect(actionsTh.attributes('aria-sort')).toBeUndefined() + }) + + it('sets aria-sort="none" on sortable inactive columns', async () => { + const wrapper = createWrapper() + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + sortingStore.sortingDirection = 'desc' + await wrapper.vm.$nextTick() + + expect(wrapper.find('th.files-list__row-status').attributes('aria-sort')).toBe('none') + expect(wrapper.find('th.files-list__row-signers').attributes('aria-sort')).toBe('none') + }) + }) }) diff --git a/src/views/FilesList/FilesListTableHeader.vue b/src/views/FilesList/FilesListTableHeader.vue index 8302d8c0f8..909753e502 100644 --- a/src/views/FilesList/FilesListTableHeader.vue +++ b/src/views/FilesList/FilesListTableHeader.vue @@ -6,6 +6,7 @@ @@ -14,7 +15,8 @@ + scope="col" + :aria-sort="ariaSortForMode('name')"> @@ -23,13 +25,14 @@ - + + :aria-sort="ariaSortForMode(column.id, !!column.sort)"> {{ column.title }} @@ -47,6 +50,7 @@ import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' import logger from '../../logger.js' import { useFilesStore } from '../../store/files.js' +import { useFilesSortingStore } from '../../store/filesSorting.js' import { useSelectionStore } from '../../store/selection.js' export default { @@ -65,17 +69,17 @@ export default { }, setup() { const filesStore = useFilesStore() + const filesSortingStore = useFilesSortingStore() const selectionStore = useSelectionStore() return { t, filesStore, + filesSortingStore, selectionStore, } }, data() { return { - isAscSorting: false, - sortingMode: 'name', columns: [ { title: t('libresign', 'Status'), @@ -120,11 +124,18 @@ export default { }, }, methods: { - ariaSortForMode(mode) { - if (this.sortingMode === mode) { - return this.isAscSorting ? 'ascending' : 'descending' + // Returns the aria-sort value for a given column mode. + // Sortable columns that are not currently active must declare aria-sort="none" + // so screen readers announce that the column can be sorted. + // Non-sortable columns should have no aria-sort attribute at all (return null). + ariaSortForMode(mode, isSortable = true) { + if (!isSortable) { + return null } - return null + if (this.filesSortingStore.sortingMode === mode) { + return this.filesSortingStore.sortingDirection === 'asc' ? 'ascending' : 'descending' + } + return 'none' }, classForColumn(column) { return { diff --git a/src/views/FilesList/FilesListTableHeaderButton.spec.ts b/src/views/FilesList/FilesListTableHeaderButton.spec.ts new file mode 100644 index 0000000000..622496bf06 --- /dev/null +++ b/src/views/FilesList/FilesListTableHeaderButton.spec.ts @@ -0,0 +1,194 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Unit tests for FilesListTableHeaderButton: + * - isAscending computed: determines which arrow icon is shown + * - --active CSS class: applied only to the column currently being sorted + * - click: delegates to filesSortingStore.toggleSortBy(mode) + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' +import FilesListTableHeaderButton from './FilesListTableHeaderButton.vue' +import { useFilesSortingStore } from '../../store/filesSorting.js' + +// --------------------------------------------------------------------------- +// Mocks (same set required by filesSorting store) +// --------------------------------------------------------------------------- + +vi.mock('@nextcloud/event-bus', () => ({ + emit: vi.fn(), + subscribe: vi.fn(), +})) + +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/auth', () => ({ + getCurrentUser: vi.fn(() => ({ uid: 'testuser', displayName: 'Test User' })), +})) + +vi.mock('@nextcloud/axios', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path) => `/ocs/v2.php${path}`), +})) + +// loadState returns the default value — store initialises with +// sortingMode = 'created_at' and sortingDirection = 'desc'. +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn((_app, _key, defaultValue) => defaultValue), +})) + +// --------------------------------------------------------------------------- +// NcButton stub — forwards $attrs so @click reaches the root button element +// --------------------------------------------------------------------------- +const NcButtonStub = { + name: 'NcButton', + template: '', +} + +// --------------------------------------------------------------------------- +// Helper +// --------------------------------------------------------------------------- +const createWrapper = (mode = 'created_at') => + mount(FilesListTableHeaderButton, { + props: { name: 'Created at', mode }, + global: { + stubs: { + NcButton: NcButtonStub, + NcIconSvgWrapper: { template: '' }, + }, + }, + }) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FilesListTableHeaderButton', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + describe('RULE: isAscending reflects store state', () => { + it('is true when the column is not the active sort mode', () => { + const wrapper = createWrapper('status') // store defaults to 'created_at' + const vm = wrapper.vm as InstanceType & { isAscending: boolean } + + expect(vm.isAscending).toBe(true) + }) + + it('is true when the column is active and direction is asc', () => { + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + sortingStore.sortingDirection = 'asc' + + const wrapper = createWrapper('created_at') + const vm = wrapper.vm as InstanceType & { isAscending: boolean } + + expect(vm.isAscending).toBe(true) + }) + + it('is false when the column is active and direction is desc', () => { + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + sortingStore.sortingDirection = 'desc' + + const wrapper = createWrapper('created_at') + const vm = wrapper.vm as InstanceType & { isAscending: boolean } + + expect(vm.isAscending).toBe(false) + }) + + it('becomes false reactively when direction changes to desc', async () => { + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + sortingStore.sortingDirection = 'asc' + + const wrapper = createWrapper('created_at') + const vm = wrapper.vm as InstanceType & { isAscending: boolean } + expect(vm.isAscending).toBe(true) + + sortingStore.sortingDirection = 'desc' + await wrapper.vm.$nextTick() + + expect(vm.isAscending).toBe(false) + }) + }) + + describe('RULE: --active class applied only to the active column', () => { + it('adds --active class when the column is the active sort mode', () => { + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + + const wrapper = createWrapper('created_at') + expect(wrapper.find('button').classes()).toContain('files-list__column-sort-button--active') + }) + + it('does not add --active class when the column is not the active sort mode', () => { + // Store defaults to 'created_at'; mount with mode='status' + const wrapper = createWrapper('status') + expect(wrapper.find('button').classes()).not.toContain('files-list__column-sort-button--active') + }) + + it('removes --active class reactively when another column becomes active', async () => { + const sortingStore = useFilesSortingStore() + sortingStore.sortingMode = 'created_at' + + const wrapper = createWrapper('created_at') + expect(wrapper.find('button').classes()).toContain('files-list__column-sort-button--active') + + sortingStore.sortingMode = 'status' + await wrapper.vm.$nextTick() + + expect(wrapper.find('button').classes()).not.toContain('files-list__column-sort-button--active') + }) + }) + + describe('RULE: click delegates to filesSortingStore.toggleSortBy(mode)', () => { + it('calls toggleSortBy with the column mode on click', async () => { + const sortingStore = useFilesSortingStore() + const spy = vi.spyOn(sortingStore, 'toggleSortBy') + + const wrapper = createWrapper('created_at') + await wrapper.find('button').trigger('click') + + expect(spy).toHaveBeenCalledOnce() + expect(spy).toHaveBeenCalledWith('created_at') + }) + + it('calls toggleSortBy with the correct mode for each column', async () => { + const sortingStore = useFilesSortingStore() + const spy = vi.spyOn(sortingStore, 'toggleSortBy') + + const wrapper = createWrapper('status') + await wrapper.find('button').trigger('click') + + expect(spy).toHaveBeenCalledWith('status') + }) + }) +}) diff --git a/src/views/FilesList/FilesListTableHeaderButton.vue b/src/views/FilesList/FilesListTableHeaderButton.vue index 23bd145aa4..c9136fc1e6 100644 --- a/src/views/FilesList/FilesListTableHeaderButton.vue +++ b/src/views/FilesList/FilesListTableHeaderButton.vue @@ -8,19 +8,20 @@ 'files-list__column-sort-button--size': filesSortingStore.sortingMode === 'size', }]" :alignment="mode === 'size' ? 'end' : 'start-reverse'" + :title="name" variant="tertiary" @click="filesSortingStore.toggleSortBy(mode)"> {{ name }} @@ -70,22 +78,24 @@ export default { font-weight: normal; } - &-icon { + :deep(.files-list__column-sort-button-icon) { color: var(--color-text-maxcontrast); opacity: 0; transition: opacity var(--animation-quick); inset-inline-start: -10px; } - &--size &-icon { + &--size :deep(.files-list__column-sort-button-icon) { inset-inline-start: 10px; } - &--active &-icon, - &:hover &-icon, - &:focus &-icon, - &:active &-icon { - opacity: 1; + &--active, + &:hover, + &:focus, + &:active { + :deep(.files-list__column-sort-button-icon) { + opacity: 1; + } } }