From 973629a073268a1c08f7aa2706430a60d8cb4fc1 Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:58:40 -0300
Subject: [PATCH 1/6] fix(files-list): fix sort icon visibility in scoped slot
and add accessibility improvements
- Replace scoped &-icon with :deep() to reach slot content in Vue 3
- Set :size="24" on NcIconSvgWrapper to match Files app icon size
- Simplify template to single NcIconSvgWrapper with computed isAscending
- Add :title="name" to NcButton for tooltip and screen reader hint
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
.../FilesList/FilesListTableHeaderButton.vue | 32 ++++++++++++-------
1 file changed, 21 insertions(+), 11 deletions(-)
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;
+ }
}
}
From 724555d33c42ebd604de73080cba5bc12460a07a Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:58:40 -0300
Subject: [PATCH 2/6] fix(files-list): connect aria-sort to filesSortingStore
and fix column accessibility
- Import and expose filesSortingStore via setup() replacing dead local data() properties
- Fix ariaSortForMode('basename') -> ariaSortForMode('name') mismatch with button mode prop
- Return 'none' instead of null for sortable-but-inactive columns (required by ARIA spec)
- Add scope="col" to all
elements so screen readers associate headers with columns
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
src/views/FilesList/FilesListTableHeader.vue | 29 ++++++++++++++------
1 file changed, 20 insertions(+), 9 deletions(-)
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 {
From 3d79330114452ea46bd9faf6bf8c0b4a7e8fae51 Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:58:40 -0300
Subject: [PATCH 3/6] test(files-list): add unit tests for
FilesListTableHeaderButton
Cover isAscending computed property, --active CSS class presence,
and toggleSortBy delegation for active/inactive column states
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
.../FilesListTableHeaderButton.spec.ts | 194 ++++++++++++++++++
1 file changed, 194 insertions(+)
create mode 100644 src/views/FilesList/FilesListTableHeaderButton.spec.ts
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')
+ })
+ })
+})
From e26b15ffe5787f138535780a8a203021a4ec0089 Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:58:40 -0300
Subject: [PATCH 4/6] test(files-list): add unit tests for ariaSortForMode in
FilesListTableHeader
Verify ascending/descending/none/null return values and corresponding
aria-sort DOM attribute for sortable and non-sortable columns
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
.../FilesList/FilesListTableHeader.spec.ts | 77 +++++++++++++++++++
1 file changed, 77 insertions(+)
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')
+ })
+ })
})
From 267abe5e231f5f30cfd6818f42435fe8ec7307ba Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:58:40 -0300
Subject: [PATCH 5/6] test(e2e): add delete pending signature request e2e test
Upload PDF, add signer, send request, navigate to Files list sorted by
Created at descending, rename to unique name, assert Ready to sign status,
delete via actions menu and confirm row disappears
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
playwright/e2e/delete-pending-request.spec.ts | 87 +++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 playwright/e2e/delete-pending-request.spec.ts
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()
+})
+
From 4fe4146218dfc37d971d5c401867b1015cca6e65 Mon Sep 17 00:00:00 2001
From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
Date: Mon, 2 Mar 2026 16:58:40 -0300
Subject: [PATCH 6/6] test(e2e): add visible signature element persistence e2e
test
Upload PDF, place a visible signature element, save, navigate away and
return to verify the element persists, then delete it and confirm it is
removed after a second round-trip
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
---
.../e2e/visible-element-persistence.spec.ts | 128 ++++++++++++++++++
1 file changed, 128 insertions(+)
create mode 100644 playwright/e2e/visible-element-persistence.spec.ts
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()
+})
| |