Skip to content
Open
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: 0 additions & 2 deletions cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ Cypress.Commands.add('deleteTable', (title) => {
cy.get('[data-cy="navigationTableItem"]').contains(title).should('not.exist')
})


Cypress.Commands.add('deleteRow', (rowIndex) => {
cy.get('[data-cy="ncTable"] [data-cy="editRowBtn"]').eq(rowIndex).click()
cy.get('[data-cy="editRowDeleteButton"]').click({ force: true })
Expand Down Expand Up @@ -282,7 +281,6 @@ Cypress.Commands.add('createSelectionColumn', (title, options, defaultOption, is
cy.get('.custom-table table tr th .cell').contains(title).should('exist')
})


Cypress.Commands.add('createSelectionMultiColumn', (title, options, defaultOptions, isFirstColumn) => {
cy.openCreateColumnModal(isFirstColumn)

Expand Down
125 changes: 125 additions & 0 deletions playwright/e2e/context-view-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { test, expect } from '../support/fixtures'
import type { Page } from '@playwright/test'
import {
createContext,
createTable,
createTextLineColumn,
createView,
loadContext,
loadTable,
openCreateRowModal,
} from '../support/commands'

const tableName = 'fruit stock'
const viewOneTitle = 'apple view'
const viewTwoTitle = 'banana view'
const contextTitle = 'test application search scope'

// Builds a table with two rows, two views of it and an application containing both views.
// Every test runs with its own random user, so the fixture is independent per test.
async function createSearchFixture(page: Page) {
await page.goto('/index.php/apps/tables')
await createTable(page, tableName)
await createTextLineColumn(page, 'Name', '', '', true)

await openCreateRowModal(page)
await page.locator('[data-cy="createRowModal"] .slot input').first().fill('apple row')
await page.locator('[data-cy="createRowSaveButton"]').click()
await expect(page.locator('[data-cy="createRowModal"]')).toBeHidden()

await openCreateRowModal(page)
await page.locator('[data-cy="createRowModal"] .slot input').first().fill('banana row')
await page.locator('[data-cy="createRowSaveButton"]').click()
await expect(page.locator('[data-cy="createRowModal"]')).toBeHidden()

await createView(page, viewOneTitle)
await loadTable(page, tableName)
await createView(page, viewTwoTitle)

await createContext(page, contextTitle, false, [viewOneTitle, viewTwoTitle])
}

// Returns the locator for the data row containing the given text inside a view's table.
function row(view: ReturnType<Page['locator']>, text: string) {
return view.locator('tbody tr').filter({ hasText: text })
}

test.describe('Search inside an application', () => {
test('Search in one view must not filter the other views', async ({
userPage: { page },
}) => {
await createSearchFixture(page)
await loadContext(page, contextTitle)

const viewOne = page
.locator('[data-cy="contextViewNode"]')
.filter({ hasText: viewOneTitle })
const viewTwo = page
.locator('[data-cy="contextViewNode"]')
.filter({ hasText: viewTwoTitle })
await expect(viewOne).toHaveCount(1)
await expect(viewTwo).toHaveCount(1)

// type into the first view's search input only
await viewOne
.locator('[data-cy="elementSearchInput"] input')
.fill('apple')

// synchronize on the debounced filter applying before asserting states
await expect(row(viewOne, 'banana row')).toHaveCount(0)

// the searched view is filtered
await expect(row(viewOne, 'apple row')).toBeVisible()

// the other view keeps being uninterrupted
await expect(row(viewTwo, 'apple row')).toBeVisible()
await expect(row(viewTwo, 'banana row')).toBeVisible()

// clearing the search un-filters the searched view
await viewOne
.locator('[data-cy="elementSearchInput"] input')
.fill('')
await expect(row(viewOne, 'banana row')).toBeVisible()
})

test('Application-wide search filters all resources at once', async ({
userPage: { page },
}) => {
await createSearchFixture(page)
await loadContext(page, contextTitle)

const viewOne = page
.locator('[data-cy="contextViewNode"]')
.filter({ hasText: viewOneTitle })
const viewTwo = page
.locator('[data-cy="contextViewNode"]')
.filter({ hasText: viewTwoTitle })
await expect(viewOne).toHaveCount(1)
await expect(viewTwo).toHaveCount(1)

await page
.locator('[data-cy="contextSearchInput"] input')
.fill('apple')

// synchronize on the debounced filter applying before asserting states
await expect(row(viewTwo, 'banana row')).toHaveCount(0)

// both views are filtered
await expect(row(viewOne, 'apple row')).toBeVisible()
await expect(row(viewOne, 'banana row')).toHaveCount(0)
await expect(row(viewTwo, 'apple row')).toBeVisible()
await expect(row(viewTwo, 'banana row')).toHaveCount(0)

// clearing the search restores both views
await page
.locator('[data-cy="contextSearchInput"] input')
.fill('')
await expect(row(viewTwo, 'banana row')).toBeVisible()
await expect(row(viewOne, 'banana row')).toBeVisible()
})
})
13 changes: 13 additions & 0 deletions playwright/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export async function createContext(
page: Page,
title: string,
showInNav: boolean = false,
resourceTitles: string[] = [],
) {
await ensureNavigationOpen(page)

Expand All @@ -341,6 +342,18 @@ export async function createContext(
await input.clear()
await input.fill(title)

for (const resourceTitle of resourceTitles) {
const resourceInput = page
.locator('[data-cy="contextResourceForm"] input')
.first()
await resourceInput.fill(resourceTitle)
await page
.locator('.vs__dropdown-option')
.filter({ hasText: resourceTitle })
.first()
.click()
}

if (showInNav) {
await page
.locator('[data-cy="createContextShowInNavSwitch"] input')
Expand Down
5 changes: 5 additions & 0 deletions src/modules/main/partials/TableView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
:columns="columns"
:element-id="element.id"
:is-view="isView"
:context-search-string="contextSearchString"
:download-title="element.title"
:can-read-rows="canReadRows"
:can-create-rows="canCreateRows"
Expand Down Expand Up @@ -67,6 +68,10 @@ export default {
type: Object,
default: null,
},
contextSearchString: {
type: String,
default: '',
},
isView: {
type: Boolean,
default: true,
Expand Down
5 changes: 5 additions & 0 deletions src/modules/main/sections/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
:columns="columns"
:element="table"
:is-view="false"
:context-search-string="contextSearchString"
:can-read-rows="canReadData(table)"
:can-create-rows="canCreateRowInElement(table)"
:can-edit-rows="canUpdateData(table)"
Expand Down Expand Up @@ -233,6 +234,10 @@ export default {
type: Object,
default: null,
},
contextSearchString: {
type: String,
default: '',
},
selectedRows: {
type: Array,
default: null,
Expand Down
5 changes: 5 additions & 0 deletions src/modules/main/sections/TableWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<ElementTitle v-model:view-setting="localViewSetting" :active-element="table" />
<TableDescription :description="table.description" :read-only="true" />
<DataTable v-model:view-setting="localViewSetting" :show-options="false" :table="table" :columns="columns" :rows="rows"
:context-search-string="contextSearchString"
@create-column="$emit('create-column')"
@import="$emit('import')"
@import-scheme="$emit('import-scheme')"
Expand Down Expand Up @@ -51,6 +52,10 @@ export default {
type: Object,
default: null,
},
contextSearchString: {
type: String,
default: '',
},
},

emits: [
Expand Down
5 changes: 5 additions & 0 deletions src/modules/main/sections/View.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
:columns="columns"
:element="view"
:is-view="true"
:context-search-string="contextSearchString"
:can-read-rows="canReadData(view)"
:can-create-rows="canCreateRowInElement(view)"
:can-edit-rows="canUpdateData(view)"
Expand Down Expand Up @@ -137,6 +138,10 @@ export default {
type: Object,
default: null,
},
contextSearchString: {
type: String,
default: '',
},

},

Expand Down
45 changes: 39 additions & 6 deletions src/pages/Context.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,30 @@
<div class="row space-L context__description">
{{ activeContext.description }}
</div>
<div class="row space-L context__search" data-cy="contextSearchInput">
<NcTextField
v-model="searchValue"
:label="t('tables', 'Search in application')"
:show-trailing-button="searchValue !== ''"
trailing-button-icon="close"
@trailing-button-click="clearSearch">
<Magnify :size="16" />
</NcTextField>
</div>
</div>

<div class="resources">
<div v-for="resource in contextResources" :key="resource.key">
<div v-if="!resource.isView" class="resource">
<div v-if="!resource.isView" class="resource" data-cy="contextTableNode">
<TableWrapper :table="resource" :columns="columns[resource.key]" :rows="rows[resource.key]"
:view-setting="viewSetting" @create-column="createColumn(false, resource)"
:view-setting="viewSettings[resource.key]" :context-search-string="searchString" @create-column="createColumn(false, resource)"
@import-scheme="openImportSchemeModal(resource)"
@import="openImportModal(resource, false)" @download-csv="downloadCSV(resource, false)"
@download-filtered-csv="rows => downloadFilteredCSV(rows, resource, false)" />
</div>
<div v-else-if="resource.isView" class="resource">
<div v-else-if="resource.isView" class="resource" data-cy="contextViewNode">
<CustomView :view="resource" :columns="columns[resource.key]" :rows="rows[resource.key]"
:view-setting="viewSetting" @create-column="createColumn(true, resource)"
:view-setting="viewSettings[resource.key]" :context-search-string="searchString" @create-column="createColumn(true, resource)"
@import="openImportModal(resource, true)" @download-csv="downloadCSV(resource, true)"
@download-filtered-csv="rows => downloadFilteredCSV(rows, resource, true)" />
</div>
Expand All @@ -47,7 +57,9 @@
<script>
import MainModals from '../modules/modals/Modals.vue'
import { mapState, mapActions, storeToRefs } from 'pinia'
import { NcIconSvgWrapper } from '@nextcloud/vue'
import { NcIconSvgWrapper, NcTextField } from '@nextcloud/vue'
import Magnify from 'vue-material-design-icons/Magnify.vue'
import debounce from 'debounce'
import TableWrapper from '../modules/main/sections/TableWrapper.vue'
import CustomView from '../modules/main/sections/View.vue'
import { emit } from '@nextcloud/event-bus'
Expand All @@ -64,6 +76,8 @@ export default {
components: {
MainModals,
NcIconSvgWrapper,
NcTextField,
Magnify,
ErrorMessage,
TableWrapper,
CustomView,
Expand All @@ -80,7 +94,9 @@ export default {
return {
loading: true,
icon: null,
viewSetting: {},
searchValue: '',
searchString: '',
viewSettings: {},
context: null,
contextResources: [],
errorMessage: null,
Expand Down Expand Up @@ -148,6 +164,10 @@ export default {
},
immediate: true,
},
// commit the search term debounced so the resources are not filtered on every keystroke
searchValue: debounce(function(value) {
this.searchString = value
}, 500),
},

async mounted() {
Expand All @@ -158,6 +178,10 @@ export default {
},

methods: {
clearSearch() {
this.searchValue = ''
this.searchString = ''
},
...mapActions(useTablesStore, ['loadContext', 'validateExportAccess', 'loadContextTable', 'loadContextView']),
...mapActions(useDataStore, ['loadColumnsFromBE', 'loadRowsFromBE', 'loadRelationsFromBE']),
contextSignature() {
Expand All @@ -175,6 +199,7 @@ export default {
this.isReloading = true
this.loading = true
this.contextResources = []
this.viewSettings = {}

try {
await this.loadContext({ id: this.activeContextId })
Expand Down Expand Up @@ -223,6 +248,7 @@ export default {
})
table.key = (table.id).toString()
table.isView = false
this.viewSettings[table.key] = {}
this.contextResources.push(table)
}

Expand All @@ -242,6 +268,7 @@ export default {
})
view.key = 'view-' + (view.id).toString()
view.isView = true
this.viewSettings[view.key] = {}
this.contextResources.push(view)
}
}
Expand Down Expand Up @@ -339,6 +366,12 @@ export default {
margin-inline-start: 32px;
}

&__search {
max-width: 400px;
margin-inline-start: 32px;
margin-block-end: calc(3 * var(--default-grid-baseline, 4px));
}

&:deep(.icon-vue) {
min-width: 32px;
min-height: 32px;
Expand Down
Loading