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
175 changes: 175 additions & 0 deletions cypress/e2e/view-filtering-datetime.cy.js
Original file line number Diff line number Diff line change
@@ -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.setUTCDate(d.getUTCDate() + days)
return d
})

const formatDate = (date) => date.toISOString().split('T')[0]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine that this test breaks if we use a different timezone? Should be fine, since it's just a test. Just confirming that the server logic itself is timezone-proof


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')
})
})
14 changes: 13 additions & 1 deletion lib/Helper/ColumnsHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down
116 changes: 113 additions & 3 deletions src/modules/main/partials/editViewPartials/filter/FilterEntry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
:placeholder="t('tables', 'Operator')"
data-cy="filterEntryOperator" />
</div>
<div class="fix-col-2">
<div class="fix-col-2" :class="{ 'has-additional-input': additionalInputType }">
<NcSelect
v-if="selectedOperator && !selectedOperator.noSearchValue"
v-model="searchValue"
Expand All @@ -34,6 +34,21 @@
:placeholder="getValuePlaceholder"
data-cy="filterEntrySeachValue"
@search="v => term = v" />
<NcDateTimePickerNative
v-if="additionalInputType === AdditionalInputTypes.DATE"
v-model="additionalInputValue"
class="additional-input"
:label="selectedMagicField?.additionalInputLabel"
type="date"
data-cy="filterEntryDate" />
<NcTextField
v-if="additionalInputType === AdditionalInputTypes.NUMBER"
v-model.number="additionalInputValue"
class="additional-input"
:label="selectedMagicField?.additionalInputLabel"
type="number"
:min="0"
data-cy="filterEntryNumber" />
</div>
<div class="fix-col-2 actions">
<NcButton
Expand All @@ -51,16 +66,20 @@
</template>

<script>
import { NcButton, NcSelect } from '@nextcloud/vue'
import { NcButton, NcSelect, NcDateTimePickerNative, NcTextField } from '@nextcloud/vue'
import Moment from '@nextcloud/moment'
import DeleteOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import { ColumnTypes } from '../../../../../shared/components/ncTable/mixins/columnHandler.js'
import { AdditionalInputTypes } from '../../../../../shared/components/ncTable/mixins/magicFields.js'

export default {

components: {
NcSelect,
NcButton,
DeleteOutline,
NcDateTimePickerNative,
NcTextField,
},

props: {
Expand All @@ -77,6 +96,8 @@ export default {
data() {
return {
term: '',
additionalInputValue: null,
AdditionalInputTypes,
}
},

Expand All @@ -98,7 +119,8 @@ export default {

// if the value starts with @, we try to load the magic-value object
if (this.filterEntry?.value.substr(0, 1) === '@') {
return this.magicFields.find(item => item.id === this.filterEntry?.value || item.id === this.filterEntry?.value.substr(1))
const magicFieldId = this.getMagicFieldId()
return this.magicFields.find(item => item.id === magicFieldId || item.id === magicFieldId.substr(1))
}

return this.filterEntry.value
Expand Down Expand Up @@ -181,6 +203,78 @@ export default {
}
return t('tables', 'Search Value')
},
selectedMagicField() {
const magicFieldId = this.getMagicFieldId()
if (!magicFieldId) return
return this.magicFields.find(item => item.id === magicFieldId || item.id === magicFieldId.substr(1))
},
additionalInputType() {
return this.selectedMagicField?.additionalInput || null
},
},

watch: {
additionalInputValue: 'applyAdditionalInput',
'selectedMagicField.id'() {
this.applyAdditionalInput()
this.focusAdditionalInput()
},
'filterEntry.value': {
handler: 'loadAdditionalInputValue',
immediate: true,
},
},

methods: {
getMagicFieldId() {
if (!this.filterEntry?.value) return null
return this.filterEntry.value.split(':')[0]
},
applyAdditionalInput() {
if (!this.additionalInputType || this.additionalInputValue == null) return

const magicFieldId = this.getMagicFieldId()
if (!magicFieldId) return

let formattedValue
if (this.additionalInputType === AdditionalInputTypes.DATE) {
if (!(this.additionalInputValue instanceof Date)) return
formattedValue = new Moment(this.additionalInputValue).format('YYYY-MM-DD')
} else if (this.additionalInputType === AdditionalInputTypes.NUMBER) {
const number = parseInt(this.additionalInputValue)
if (isNaN(number) || number < 0) return
formattedValue = number
} else {
return
}

const newFilterValue = `${magicFieldId}:${formattedValue}`
if (this.filterEntry.value !== newFilterValue) {
this.mutableFilterEntry.value = newFilterValue
}
},
focusAdditionalInput() {
if (!this.additionalInputType) return

this.$nextTick(() => {
const input = this.$el.querySelector('.additional-input input')
if (input) {
input.focus()
}
})
},
loadAdditionalInputValue(newValue) {
if (!newValue?.includes(':')) return

const extractedValue = newValue.split(':')[1]
if (this.additionalInputValue === extractedValue) return

if (this.additionalInputType === AdditionalInputTypes.DATE) {
this.additionalInputValue = new Moment(extractedValue, 'YYYY-MM-DD').toDate()
} else if (this.additionalInputType === AdditionalInputTypes.NUMBER) {
this.additionalInputValue = parseInt(extractedValue)
}
},
},
}
</script>
Expand All @@ -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;
Expand Down
Loading
Loading