From 4aaefef3d716be69a0f98f4036caceb2a4bc82f1 Mon Sep 17 00:00:00 2001 From: Benjamin Frueh Date: Fri, 10 Oct 2025 00:22:41 +0200 Subject: [PATCH 1/2] feat: add additional date filter options Resolves #1758 Signed-off-by: Benjamin Frueh --- cypress/e2e/view-filtering-datetime.cy.js | 175 ++++++++++++++++++ lib/Helper/ColumnsHelper.php | 14 +- .../editViewPartials/filter/FilterEntry.vue | 116 +++++++++++- .../components/ncTable/mixins/magicFields.js | 42 ++++- 4 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 cypress/e2e/view-filtering-datetime.cy.js diff --git a/cypress/e2e/view-filtering-datetime.cy.js b/cypress/e2e/view-filtering-datetime.cy.js new file mode 100644 index 0000000000..e43e4adf2b --- /dev/null +++ b/cypress/e2e/view-filtering-datetime.cy.js @@ -0,0 +1,175 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +let localUser +const tableTitle = 'View datetime filtering test table' + +const today = new Date() +const [tomorrow, yesterday, daysAhead30, daysAhead60, daysAgo30, daysAgo60] = [1, -1, 30, 60, -30, -60].map(days => { + const d = new Date() + d.setDate(d.getDate() + days) + return d +}) + +const formatDate = (date) => date.toISOString().split('T')[0] + +describe('Filtering in a view by datetime', () => { + + before(function () { + cy.createRandomUser().then(user => { + localUser = user + }) + }) + + beforeEach(function () { + cy.login(localUser) + cy.visit('apps/tables') + }) + + it('Setup table', () => { + cy.createTable(tableTitle) + cy.createTextLineColumn('title', null, null, true) + cy.createDatetimeDateColumn('date', false, false) + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'today') + cy.get('.modal__content input.native-datetime-picker--input').clear().type(formatDate(today)) + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'tomorrow') + cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(tomorrow)) + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', 'yesterday') + cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(yesterday)) + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', '30 days ahead') + cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAhead30)) + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', '60 days ahead') + cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAhead60)) + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', '30 days ago') + cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAgo30)) + cy.get('[data-cy="createRowSaveButton"]').click() + + // add row + cy.get('[data-cy="createRowBtn"]').click() + cy.fillInValueTextLine('title', '60 days ago') + cy.get('.modal__content input.native-datetime-picker--input').type(formatDate(daysAgo60)) + cy.get('[data-cy="createRowSaveButton"]').click() + }) + + it('Filter view for dates 1-30 days ahead', () => { + cy.loadTable(tableTitle) + + // create view + const title = 'Next 30 days' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type(title) + + // add filter for >= 1 day ahead + cy.get('[data-cy="filterFormFilterGroupBtn"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="date"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Is greater than or equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="Number of days ahead"]').click() + cy.get('[data-cy="filterEntryNumber"]').eq(0).type('1') + + // add filter for <= 30 days ahead + cy.get('[data-cy="filterGroupAddFilterBtn"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(3).click() + cy.get('ul.vs__dropdown-menu li span[title="date"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(4).click() + cy.get('ul.vs__dropdown-menu li span[title="Is lower than or equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(5).click() + cy.get('ul.vs__dropdown-menu li span[title="Number of days ahead"]').click() + cy.get('[data-cy="filterEntryNumber"]').eq(1).type('30') + + // save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // check for existing rows + cy.get('.custom-table table tr td div').contains('tomorrow').should('be.visible') + cy.get('.custom-table table tr td div').contains('30 days ahead').should('be.visible') + + // check for not existing rows + cy.get('.custom-table table tr td div').contains('today').should('not.exist') + cy.get('.custom-table table tr td div').contains('yesterday').should('not.exist') + cy.get('.custom-table table tr td div').contains('60 days ahead').should('not.exist') + cy.get('.custom-table table tr td div').contains('30 days ago').should('not.exist') + cy.get('.custom-table table tr td div').contains('60 days ago').should('not.exist') + }) + + it('Filter view for dates 1-30 days ago', () => { + cy.loadTable(tableTitle) + + // create view + const title = 'Last 30 days' + cy.get('[data-cy="customTableAction"] button').click() + cy.get('[data-cy="dataTableCreateViewBtn"]').contains('Create view').click({ force: true }) + cy.get('[data-cy="viewSettingsDialogSection"] input').type(title) + + // add filter for <= 1 day ago + cy.get('[data-cy="filterFormFilterGroupBtn"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(0).click() + cy.get('ul.vs__dropdown-menu li span[title="date"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(1).click() + cy.get('ul.vs__dropdown-menu li span[title="Is lower than or equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(2).click() + cy.get('ul.vs__dropdown-menu li span[title="Number of days ago"]').click() + cy.get('[data-cy="filterEntryNumber"]').eq(0).type('1') + + // add filter for >= 30 days ago + cy.get('[data-cy="filterGroupAddFilterBtn"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(3).click() + cy.get('ul.vs__dropdown-menu li span[title="date"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(4).click() + cy.get('ul.vs__dropdown-menu li span[title="Is greater than or equal"]').click() + cy.get('.modal-container .filter-group .v-select.select').eq(5).click() + cy.get('ul.vs__dropdown-menu li span[title="Number of days ago"]').click() + cy.get('[data-cy="filterEntryNumber"]').eq(1).type('30') + + // save view + cy.intercept({ method: 'POST', url: '**/apps/tables/view' }).as('createView') + cy.intercept({ method: 'PUT', url: '**/apps/tables/view/*' }).as('updateView') + cy.contains('button', 'Create View').click() + cy.wait('@createView') + cy.wait('@updateView') + cy.contains('.app-navigation-entry-link span', title).should('exist') + + // check for existing rows + cy.get('.custom-table table tr td div').contains('yesterday').should('be.visible') + cy.get('.custom-table table tr td div').contains('30 days ago').should('be.visible') + + // check for not existing rows + cy.get('.custom-table table tr td div').contains('today').should('not.exist') + cy.get('.custom-table table tr td div').contains('tomorrow').should('not.exist') + cy.get('.custom-table table tr td div').contains('30 days ahead').should('not.exist') + cy.get('.custom-table table tr td div').contains('60 days ahead').should('not.exist') + cy.get('.custom-table table tr td div').contains('60 days ago').should('not.exist') + }) +}) \ No newline at end of file diff --git a/lib/Helper/ColumnsHelper.php b/lib/Helper/ColumnsHelper.php index 50a1c1c7ec..035dfcc1b6 100644 --- a/lib/Helper/ColumnsHelper.php +++ b/lib/Helper/ColumnsHelper.php @@ -30,7 +30,12 @@ public function resolveSearchValue(string $placeholder, string $userId, ?Column if (str_starts_with($placeholder, '@selection-id-')) { return substr($placeholder, 14); } - switch (ltrim($placeholder, '@')) { + + $placeholderParts = explode(':', $placeholder, 2); + $placeholderName = ltrim($placeholderParts[0], '@'); + $additionalValue = $placeholderParts[1] ?? null; + + switch ($placeholderName) { case 'me': if ($column?->getType() !== Column::TYPE_USERGROUP) { return $userId; @@ -64,6 +69,13 @@ public function resolveSearchValue(string $placeholder, string $userId, ?Column return $result ?: ''; case 'datetime-time-now': return date('H:i'); case 'datetime-now': return date('Y-m-d H:i') ? date('Y-m-d H:i') : ''; + case 'datetime-exact-date': return $additionalValue ?? ''; + case 'datetime-days-ahead': + $days = max(0, (int)$additionalValue); + return date('Y-m-d', strtotime("+{$days} days")) ?: ''; + case 'datetime-days-ago': + $days = max(0, (int)$additionalValue); + return date('Y-m-d', strtotime("-{$days} days")) ?: ''; default: return $placeholder; } } diff --git a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue index 19f6a14ed8..2bc7f64a37 100644 --- a/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue +++ b/src/modules/main/partials/editViewPartials/filter/FilterEntry.vue @@ -24,7 +24,7 @@ :placeholder="t('tables', 'Operator')" data-cy="filterEntryOperator" /> -
+
+ +
@@ -207,6 +301,22 @@ export default { align-items: center; } + .row .fix-col-2.has-additional-input { + height: auto; + min-height: 63.32px; + flex-wrap: wrap; + padding-bottom: 14px; + } + + .row .fix-col-2 .additional-input :deep(.input-field__main-wrapper) { + --input-border-width-offset: 2px; + } + + .row .fix-col-2 .additional-input :deep(.input-field__main-wrapper:has(input:focus)) { + padding: var(--border-width-input, 2px); + --input-border-width-offset: 0; + } + .actions button { margin-inline: auto calc(var(--default-grid-baseline) * 2); height: 44px; diff --git a/src/shared/components/ncTable/mixins/magicFields.js b/src/shared/components/ncTable/mixins/magicFields.js index 57900fe8f5..3bcab63738 100644 --- a/src/shared/components/ncTable/mixins/magicFields.js +++ b/src/shared/components/ncTable/mixins/magicFields.js @@ -18,13 +18,15 @@ class BaseMagicField { class MagicField extends BaseMagicField { - constructor({ id, label, icon, goodFor, replace } = {}) { + constructor({ id, label, icon, goodFor, replace, additionalInput, additionalInputLabel } = {}) { super() this.id = id this.label = label this.icon = icon this.goodFor = goodFor this.replace = replace + this.additionalInput = additionalInput + this.additionalInputLabel = additionalInputLabel } } @@ -33,6 +35,11 @@ export function getMagicFieldWithId(id) { return Object.values(MagicFields).find(mf => mf.id === id) } +export const AdditionalInputTypes = { + DATE: 'date', + NUMBER: 'number', +} + export const MagicFields = { Me: new MagicField({ id: 'me', @@ -113,21 +120,21 @@ export const MagicFields = { }), DatetimeDateStartOfYear: new MagicField({ id: 'datetime-date-start-of-year', - label: t('tables', 'Start of the year'), + label: t('tables', 'This year'), icon: 'icon-history', goodFor: [ColumnTypes.DatetimeDate], replace: new Moment().startOf('year').format('YYYY-MM-DD'), }), DatetimeDateStartOfMonth: new MagicField({ id: 'datetime-date-start-of-month', - label: t('tables', 'Start of the month'), + label: t('tables', 'This month'), icon: 'icon-history', goodFor: [ColumnTypes.DatetimeDate], replace: new Moment().startOf('month').format('YYYY-MM-DD'), }), DatetimeDateStartOfWeek: new MagicField({ id: 'datetime-date-start-of-week', - label: t('tables', 'Start of the week'), + label: t('tables', 'This week'), icon: 'icon-history', goodFor: [ColumnTypes.DatetimeDate], replace: new Moment().startOf('week').format('YYYY-MM-DD'), @@ -146,4 +153,31 @@ export const MagicFields = { goodFor: [ColumnTypes.Datetime], replace: new Moment().format('YYYY-MM-DD HH:mm'), }), + DatetimeExactDate: new MagicField({ + id: 'datetime-exact-date', + label: t('tables', 'Exact date'), + icon: 'icon-calendar-dark', + goodFor: [ColumnTypes.DatetimeDate, ColumnTypes.Datetime], + replace: null, + additionalInput: AdditionalInputTypes.DATE, + additionalInputLabel: t('tables', 'Select a date'), + }), + DatetimeDaysAhead: new MagicField({ + id: 'datetime-days-ahead', + label: t('tables', 'Number of days ahead'), + icon: 'icon-calendar-dark', + goodFor: [ColumnTypes.DatetimeDate, ColumnTypes.Datetime], + replace: null, + additionalInput: AdditionalInputTypes.NUMBER, + additionalInputLabel: t('tables', 'Enter number of days'), + }), + DatetimeDaysAgo: new MagicField({ + id: 'datetime-days-ago', + label: t('tables', 'Number of days ago'), + icon: 'icon-calendar-dark', + goodFor: [ColumnTypes.DatetimeDate, ColumnTypes.Datetime], + replace: null, + additionalInput: AdditionalInputTypes.NUMBER, + additionalInputLabel: t('tables', 'Enter number of days'), + }), } From 008d353f0492b5884835065910cd2888505af724 Mon Sep 17 00:00:00 2001 From: Benjamin Frueh Date: Tue, 21 Oct 2025 12:24:02 +0200 Subject: [PATCH 2/2] test: use UTC date in cypress e2e test Signed-off-by: Benjamin Frueh --- cypress/e2e/view-filtering-datetime.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/view-filtering-datetime.cy.js b/cypress/e2e/view-filtering-datetime.cy.js index e43e4adf2b..c34d36d745 100644 --- a/cypress/e2e/view-filtering-datetime.cy.js +++ b/cypress/e2e/view-filtering-datetime.cy.js @@ -8,7 +8,7 @@ const tableTitle = 'View datetime filtering test table' const today = new Date() const [tomorrow, yesterday, daysAhead30, daysAhead60, daysAgo30, daysAgo60] = [1, -1, 30, 60, -30, -60].map(days => { const d = new Date() - d.setDate(d.getDate() + days) + d.setUTCDate(d.getUTCDate() + days) return d })