Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 87 additions & 0 deletions playwright/e2e/delete-pending-request.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' }).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()
})

128 changes: 128 additions & 0 deletions playwright/e2e/visible-element-persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]' }).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()
})
77 changes: 77 additions & 0 deletions src/views/FilesList/FilesListTableHeader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof FilesListTableHeader> & { 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<typeof FilesListTableHeader> & { 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<typeof FilesListTableHeader> & { 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<typeof FilesListTableHeader> & { ariaSortForMode: (mode: string) => string | null }
expect(vm.ariaSortForMode('status')).toBe('ascending')
})

it('sets aria-sort attribute on the active <th> 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')
})
})
})
29 changes: 20 additions & 9 deletions src/views/FilesList/FilesListTableHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<tr v-if="filesStore.ordered.length > 0"
class="files-list__row-head">
<th class="files-list__column files-list__row-checkbox"
scope="col"
@keyup.esc.exact="resetSelection">
<NcCheckboxRadioSwitch v-bind="selectAllBind" @update:modelValue="onToggleAll" />
</th>
Expand All @@ -14,7 +15,8 @@

<!-- Link to file -->
<th class="files-list__column files-list__row-name files-list__column--sortable"
:aria-sort="ariaSortForMode('basename')">
scope="col"
:aria-sort="ariaSortForMode('name')">
<!-- Icon or preview -->
<span class="files-list__row-icon" />

Expand All @@ -23,13 +25,14 @@
</th>

<!-- Actions -->
<th class="files-list__row-actions" />
<th class="files-list__row-actions" scope="col" />

<!-- Custom views columns -->
<th v-for="column in columns"
:key="column.id"
scope="col"
:class="classForColumn(column)"
:aria-sort="ariaSortForMode(column.id)">
:aria-sort="ariaSortForMode(column.id, !!column.sort)">
<FilesListTableHeaderButton v-if="!!column.sort" :name="column.title" :mode="column.id" />
<span v-else>
{{ column.title }}
Expand All @@ -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 {
Expand All @@ -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'),
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading