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
2 changes: 1 addition & 1 deletion cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Cypress.Commands.add('openContextEditModal', (title) => {

Cypress.Commands.add('clickOnTableThreeDotMenu', (optionName) => {
cy.get('[data-cy="customTableAction"] button').click()
cy.get('[data-cy="dataTableExportBtn"]').contains(optionName).click({ force: true })
cy.get('.v-popper__popper button, [role="menuitem"]').contains(optionName).click({ force: true })
})

Cypress.Commands.add('sortTableColumn', (columnTitle, mode = 'ASC') => {
Expand Down
109 changes: 105 additions & 4 deletions playwright/e2e/tables-export-csv.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,46 @@

import { test, expect } from '../support/fixtures'
import * as fs from 'fs'
import { type Page } from '@playwright/test'
import { clickOnTableThreeDotMenu, getTutorialTableName, loadTable } from '../support/commands'

test.describe('Import csv', () => {
async function fillSearchInput(page: Page, value: string) {
// Scope to the NcTable container to avoid matching Nextcloud header search elements
const searchInput = page.locator('[data-cy="ncTable"]').getByRole('textbox', { name: 'Search' })
await expect(searchInput).toBeVisible({ timeout: 10000 })
await searchInput.fill(value)
await page.waitForTimeout(600) // debounce in SearchForm is 500 ms
}

test('Export csv', async ({ userPage: { page } }) => {
async function clickSelectionBarAction(page: Page, label: string) {
await expect(page.locator('.icon-loading').first()).toBeHidden({ timeout: 10000 })
await expect(page.locator('.selected-rows-option')).toBeVisible({ timeout: 10000 })
// NcActionButton does not forward data-cy to the DOM; match by button text content instead.
// With inline=2 the items render as plain <button> elements directly in the selection bar.
const item = page.locator('.selected-rows-option button').filter({ hasText: label })
await expect(item.first()).toBeVisible({ timeout: 5000 })
await item.first().click()
}

async function selectFirstRow(page: Page) {
const checkbox = page.locator('[data-cy="customTableRow"]:first-of-type input[type="checkbox"]').first()
await expect(checkbox).toBeVisible({ timeout: 10000 })
await checkbox.click({ force: true })
await expect(checkbox).toBeChecked()
}

test.describe('CSV export', () => {

test('Export all rows is always available in the three-dot menu', async ({ userPage: { page } }) => {
await page.goto('/index.php/apps/tables')
await loadTable(page, 'Welcome to Nextcloud Tables!')

const tutorialName = await getTutorialTableName(page)
const fileNamePattern = new RegExp(`^\\d{2}-\\d{2}-\\d{2}_\\d{2}-\\d{2}_${tutorialName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\.csv$`)
const fileNamePattern = new RegExp(`^\\d{2}-\\d{2}-\\d{2}_\\d{2}-\\d{2}_${tutorialName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.csv$`)

const [download] = await Promise.all([
page.waitForEvent('download'),
clickOnTableThreeDotMenu(page, 'Export as CSV'),
clickOnTableThreeDotMenu(page, 'Export all rows'),
])

expect(download.suggestedFilename()).toMatch(fileNamePattern)
Expand All @@ -29,4 +55,79 @@ test.describe('Import csv', () => {
expect(content).toContain('What,How to do,Ease of use,Done')
expect(content).toContain('Open the tables app,Reachable via the Tables icon in the apps list.,5,true')
})

test('Export filtered rows only appears in three-dot menu when a filter is active', async ({ userPage: { page } }) => {
await page.goto('/index.php/apps/tables')
await loadTable(page, 'Welcome to Nextcloud Tables!')

await fillSearchInput(page, 'Open the tables app')

// Export filtered and verify only the matching row is in the CSV
const [download] = await Promise.all([
page.waitForEvent('download'),
clickOnTableThreeDotMenu(page, 'Export filtered rows'),
])

const path = await download.path()
const content = fs.readFileSync(path, 'utf8')

expect(content).toContain('Open the tables app')
expect(content).not.toContain('Add a new column')
})

test('Export selected rows appears in selection bar when rows are checked', async ({ userPage: { page } }) => {
await page.goto('/index.php/apps/tables')
await loadTable(page, 'Welcome to Nextcloud Tables!')

await selectFirstRow(page)

// Export selected — should only contain 1 data row
const [download] = await Promise.all([
page.waitForEvent('download'),
clickSelectionBarAction(page, 'Export selected rows'),
])

const path = await download.path()
const content = fs.readFileSync(path, 'utf8')
const lines = content.trim().split('\n')

// Header + exactly 1 data row
expect(lines).toHaveLength(2)
})

test('Export filtered rows appears in selection bar when rows are selected and filter is active', async ({ userPage: { page } }) => {
await page.goto('/index.php/apps/tables')
await loadTable(page, 'Welcome to Nextcloud Tables!')

await fillSearchInput(page, 'Open')
await selectFirstRow(page)

// Both export buttons must be visible in the selection bar
await expect(page.locator('.selected-rows-option')).toBeVisible({ timeout: 10000 })
for (const label of ['Export selected rows', 'Export filtered rows']) {
await expect(
page.locator('.selected-rows-option button').filter({ hasText: label }).first(),
).toBeVisible({ timeout: 5000 })
}
})

test('Export all rows includes unfiltered data even when filter is active', async ({ userPage: { page } }) => {
await page.goto('/index.php/apps/tables')
await loadTable(page, 'Welcome to Nextcloud Tables!')

await fillSearchInput(page, 'Open the tables app')

// Export ALL rows — must include rows not matching the filter
const [download] = await Promise.all([
page.waitForEvent('download'),
clickOnTableThreeDotMenu(page, 'Export all rows'),
])

const path = await download.path()
const content = fs.readFileSync(path, 'utf8')

expect(content).toContain('Open the tables app')
expect(content).toContain('Add a new column')
})

})
10 changes: 6 additions & 4 deletions playwright/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@

const menuButton = page.locator('[data-cy="customTableAction"] button').first()
const anyMenuAction = page.locator(
'[data-cy="dataTableEditTableBtn"], [data-cy="dataTableCreateViewBtn"], [data-cy="dataTableCreateColumnBtn"], [data-cy="dataTableShareBtn"], [data-cy="dataTableExportBtn"]',
'[data-cy="dataTableEditTableBtn"], [data-cy="dataTableCreateViewBtn"], [data-cy="dataTableCreateColumnBtn"], [data-cy="dataTableShareBtn"], [data-cy="dataTableExportAllBtn"]',
)

for (let attempt = 1; attempt <= 3; attempt++) {
Expand Down Expand Up @@ -97,9 +97,11 @@
case 'Share':
return page.locator('[data-cy="dataTableShareBtn"]')
case 'Import':
return page.locator('[data-cy="dataTableExportBtn"]').filter({ hasText: /^Import$/ })
case 'Export as CSV':
return page.locator('[data-cy="dataTableExportBtn"]').filter({ hasText: /^Export as CSV$/ })
return page.locator('[data-cy="dataTableImportBtn"]')
case 'Export all rows':
return page.locator('[data-cy="dataTableExportAllBtn"]')
case 'Export filtered rows':
return page.locator('[data-cy="dataTableExportFilteredBtn"]')
default:
return null
}
Expand Down Expand Up @@ -419,10 +421,10 @@
.filter({ hasText: title })
.first()
await contextItem.waitFor({ state: 'visible', timeout: 10000 })
await contextItem.scrollIntoViewIfNeeded()

Check failure on line 424 in playwright/support/commands.ts

View workflow job for this annotation

GitHub Actions / Playwright (master)

[chromium] › playwright/e2e/context.spec.ts:100:2 › Manage a context › Share context with resources

2) [chromium] › playwright/e2e/context.spec.ts:100:2 › Manage a context › Share context with resources Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: locator.scrollIntoViewIfNeeded: Target page, context or browser has been closed Call log: - attempting scroll into view action - waiting for element to be stable at support/commands.ts:424 422 | .first() 423 | await contextItem.waitFor({ state: 'visible', timeout: 10000 }) > 424 | await contextItem.scrollIntoViewIfNeeded() | ^ 425 | const contextLink = contextItem.locator('a').first() 426 | await navigateViaNavLink(page, contextLink) 427 | await expect(page.locator('.icon-loading').first()).toBeHidden() at loadContext (/home/runner/actions-runner/_work/tables/tables/playwright/support/commands.ts:424:20) at /home/runner/actions-runner/_work/tables/tables/playwright/e2e/context.spec.ts:129:3

Check failure on line 424 in playwright/support/commands.ts

View workflow job for this annotation

GitHub Actions / Playwright (master)

[chromium] › playwright/e2e/context.spec.ts:100:2 › Manage a context › Share context with resources

2) [chromium] › playwright/e2e/context.spec.ts:100:2 › Manage a context › Share context with resources Error: locator.scrollIntoViewIfNeeded: Target page, context or browser has been closed Call log: - attempting scroll into view action - waiting for element to be stable at support/commands.ts:424 422 | .first() 423 | await contextItem.waitFor({ state: 'visible', timeout: 10000 }) > 424 | await contextItem.scrollIntoViewIfNeeded() | ^ 425 | const contextLink = contextItem.locator('a').first() 426 | await navigateViaNavLink(page, contextLink) 427 | await expect(page.locator('.icon-loading').first()).toBeHidden() at loadContext (/home/runner/actions-runner/_work/tables/tables/playwright/support/commands.ts:424:20) at /home/runner/actions-runner/_work/tables/tables/playwright/e2e/context.spec.ts:129:3
const contextLink = contextItem.locator('a').first()
await navigateViaNavLink(page, contextLink)
await expect(page.locator('.icon-loading').first()).toBeHidden()

Check failure on line 427 in playwright/support/commands.ts

View workflow job for this annotation

GitHub Actions / Playwright (master)

[chromium] › playwright/e2e/context.spec.ts:100:2 › Manage a context › Share context with resources

2) [chromium] › playwright/e2e/context.spec.ts:100:2 › Manage a context › Share context with resources Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toBeHidden() failed Locator: locator('.icon-loading').first() Expected: hidden Received: visible Call log: - Expect "toBeHidden" with timeout 5000ms - waiting for locator('.icon-loading').first() 2 × locator resolved to <div data-v-1e88b9bf="" class="icon-loading"></div> - unexpected value "visible" at support/commands.ts:427 425 | const contextLink = contextItem.locator('a').first() 426 | await navigateViaNavLink(page, contextLink) > 427 | await expect(page.locator('.icon-loading').first()).toBeHidden() | ^ 428 | } 429 | 430 | export async function unifiedSearch(page: Page, term: string) { at loadContext (/home/runner/actions-runner/_work/tables/tables/playwright/support/commands.ts:427:54) at /home/runner/actions-runner/_work/tables/tables/playwright/e2e/context.spec.ts:120:3
}

export async function unifiedSearch(page: Page, term: string) {
Expand Down
7 changes: 4 additions & 3 deletions src/modules/main/partials/TableView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@
@delete-column="deleteColumn"
@create-row="createRow"
@edit-row="editRow"
@delete-selected-rows="deleteSelectedRows">
<template #actions>
<slot name="actions" />
@delete-selected-rows="deleteSelectedRows"
@download-filtered-csv="rows => $emit('download-filtered-csv', rows)">
<template #actions="slotProps">
<slot name="actions" v-bind="slotProps" />
</template>
</NcTable>
</template>
Expand Down
39 changes: 29 additions & 10 deletions src/modules/main/sections/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,13 @@
</template>
{{ t('tables', 'Import') }}
</NcActionButton>
<NcActionButton v-if="canReadData(table)" :close-after-click="true"
icon="icon-download"
<NcActionButton v-if="canReadData(table)"
:close-after-click="true"
@click="$emit('download-csv')">
{{ t('tables', 'Export as CSV') }}
<template #icon>
<TrayArrowDown :size="20" decorative />
</template>
{{ t('tables', 'Export all rows') }}
</NcActionButton>
<NcActionButton v-if="canShareElement(table)"
:close-after-click="true"
Expand Down Expand Up @@ -84,8 +87,9 @@
:can-create-columns="canManageTable(table)"
:can-edit-columns="canManageTable(table)"
:can-delete-columns="canManageTable(table)"
:can-delete-table="canManageTable(table)">
<template #actions>
:can-delete-table="canManageTable(table)"
@download-filtered-csv="rows => $emit('download-filtered-csv', rows)">
<template #actions="{ isFiltered, onExportFiltered }">
<NcActions :force-menu="true" :type="isViewSettingSet ? 'secondary' : 'tertiary'">
<NcActionCaption v-if="canManageElement(table)" :name="t('tables', 'Manage table')" />
<NcActionButton v-if="canManageElement(table)"
Expand Down Expand Up @@ -115,16 +119,29 @@
<NcActionCaption :name="t('tables', 'Integration')" />
<NcActionButton v-if="canCreateRowInElement(table)"
:close-after-click="true"
data-cy="dataTableExportBtn" @click="$emit('import', table)">
data-cy="dataTableImportBtn" @click="$emit('import', table)">
<template #icon>
<Import :size="20" decorative title="Import" />
</template>
{{ t('tables', 'Import') }}
</NcActionButton>
<NcActionButton v-if="canReadData(table)" :close-after-click="true"
icon="icon-download"
data-cy="dataTableExportBtn" @click="$emit('download-csv')">
{{ t('tables', 'Export as CSV') }}
<NcActionButton v-if="canReadData(table)"
:close-after-click="true"
data-cy="dataTableExportAllBtn"
@click="$emit('download-csv')">
<template #icon>
<TrayArrowDown :size="20" decorative />
</template>
{{ t('tables', 'Export all rows') }}
</NcActionButton>
<NcActionButton v-if="canReadData(table) && isFiltered"
:close-after-click="true"
data-cy="dataTableExportFilteredBtn"
@click="onExportFiltered">
<template #icon>
<TrayArrowDown :size="20" decorative />
</template>
{{ t('tables', 'Export filtered rows') }}
</NcActionButton>
<NcActionButton v-if="canShareElement(table)"
data-cy="dataTableShareBtn"
Expand Down Expand Up @@ -157,6 +174,7 @@ import IconTool from 'vue-material-design-icons/TableCog.vue'
import TableView from '../partials/TableView.vue'
import EmptyTable from './EmptyTable.vue'
import Connection from 'vue-material-design-icons/Connection.vue'
import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue'
import Import from 'vue-material-design-icons/Import.vue'
import { NcActionButton, NcActions, NcActionCaption } from '@nextcloud/vue'
import { mapState } from 'pinia'
Expand All @@ -169,6 +187,7 @@ export default {
TableView,
NcActionButton,
Connection,
TrayArrowDown,
NcActionCaption,
NcActions,
TableColumnPlusAfter,
Expand Down
17 changes: 17 additions & 0 deletions src/modules/main/sections/MainWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
@create-column="createColumn"
@import="openImportModal"
@download-csv="downloadCSV"
@download-filtered-csv="downloadFilteredCSV"
@toggle-share="toggleShare"
@show-integration="showIntegration" />
<CustomTable v-else
Expand All @@ -25,6 +26,7 @@
@create-column="createColumn"
@import="openImportModal"
@download-csv="downloadCSV"
@download-filtered-csv="downloadFilteredCSV"
@toggle-share="toggleShare"
@show-integration="showIntegration" />
</div>
Expand Down Expand Up @@ -120,6 +122,21 @@ export default {

this.downloadCsv(this.rows, this.columns, this.element.title)
},
async downloadFilteredCSV(rows) {
const access = await this.validateExportAccess({
id: this.element.id,
isView: this.isView,
})

if (!access?.ok) {
if (access?.reason === 'NO_ACCESS') {
showError(t('tables', 'Your access was revoked. Reload the page to update your permissions.'))
}
return
}

this.downloadCsv(rows, this.columns, this.element.title)
},
toggleShare() {
emit('tables:sidebar:sharing', { open: true, tab: 'sharing' })
},
Expand Down
27 changes: 23 additions & 4 deletions src/modules/main/sections/PublicElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,24 @@
:can-edit-columns="false"
:can-delete-columns="false"
:can-delete-table="false"
:is-form-mode="isFormMode">
<template #actions>
:is-form-mode="isFormMode"
@download-filtered-csv="rows => $emit('download-filtered-csv', rows)">
<template #actions="{ isFiltered, onExportFiltered }">
<NcActions :force-menu="true" type="tertiary">
<NcActionButton :close-after-click="true" icon="icon-download" data-cy="dataTableExportBtn"
<NcActionButton :close-after-click="true" data-cy="dataTableExportBtn"
@click="$emit('download-csv')">
{{ t('tables', 'Export as CSV') }}
<template #icon>
<TrayArrowDown :size="20" decorative />
</template>
{{ t('tables', 'Export all rows') }}
</NcActionButton>
<NcActionButton v-if="isFiltered" :close-after-click="true"
data-cy="dataTableExportFilteredBtn"
@click="onExportFiltered">
<template #icon>
<TrayArrowDown :size="20" decorative />
</template>
{{ t('tables', 'Export filtered rows') }}
</NcActionButton>
</NcActions>
</template>
Expand Down Expand Up @@ -62,6 +74,8 @@ import CreateRow from '../../modals/CreateRow.vue'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import EditRow from '../../modals/EditRow.vue'
import DeleteRows from '../../modals/DeleteRows.vue'
import TrayArrowDown from 'vue-material-design-icons/TrayArrowDown.vue'
import { translate as t } from '@nextcloud/l10n'

export default {
name: 'PublicElement',
Expand All @@ -70,6 +84,7 @@ export default {
DeleteRows,
EditRow,
EmptyView,
TrayArrowDown,
TableView,
NcActions,
NcActionButton,
Expand Down Expand Up @@ -123,5 +138,9 @@ export default {
unsubscribe('tables:row:edit')
unsubscribe('tables:row:delete')
},

methods: {
t,
},
}
</script>
Loading
Loading