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
133 changes: 133 additions & 0 deletions cypress/e2e/boardFilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randUser } from '../utils/index.js'

const user = randUser()

// sampleBoard() only ships a single card, so filtering would have nothing to discriminate
const filterBoard = {
title: 'FilterBoard',
color: '00ff00',
stacks: [
{
title: 'TestList',
cards: [
{ title: 'Alpha task' },
{ title: 'Beta task' },
{ title: 'Gamma thing' },
],
},
],
}

const otherBoard = {
title: 'UnrelatedBoard',
color: 'ff0000',
stacks: [],
}

describe('Board filter', function() {
let boardId

before(function() {
cy.createUser(user)
cy.login(user)
cy.createExampleBoard({ user, board: filterBoard }).then((board) => {
boardId = board.id
})
cy.createExampleBoard({ user, board: otherBoard })
})

describe('On a board', function() {
beforeEach(function() {
cy.login(user)
cy.visit(`/apps/deck/#/board/${boardId}`)
cy.get('.board .card').should('have.length', 3)
})

it('Filters cards as you type', function() {
cy.get('#deck-search-input').type('Alpha')

cy.get('.board .card').should('have.length', 1)
cy.get('.board .card:contains("Alpha task")').should('be.visible')
})

it('Restores all cards when the filter is cleared', function() {
cy.get('#deck-search-input').type('Alpha')
cy.get('.board .card').should('have.length', 1)

cy.get('.board-filter .input-field__trailing-button').click()

cy.get('#deck-search-input').should('have.value', '')
cy.get('.board .card').should('have.length', 3)
})

it('Supports the title: prefix', function() {
cy.get('#deck-search-input').type('title:Gamma')

cy.get('.board .card').should('have.length', 1)
cy.get('.board .card:contains("Gamma thing")').should('be.visible')
})

// Deliberately not asserting that focus lands on the field: core's unified search
// also binds Ctrl+F and only yields on paths listed in its appHandlesSearchShortcut,
// so the outcome depends on the server version. What Deck owns is that the handler
// no longer throws — it used to call .focus() on an element that was never rendered.
// Cypress fails a test on any uncaught exception, so this assertion is implicit.
it('Handles Ctrl+F without throwing', function() {
cy.get('body').type('{ctrl}f')

cy.get('#deck-search-input').should('exist')
})
})

describe('On the board list', function() {
// Assert on the specific boards rather than on a total: a new user also gets Deck's
// default "Welcome to Nextcloud Deck!" board, so the row count is not ours to predict.
beforeEach(function() {
cy.login(user)
cy.visit('/apps/deck/#/board')
cy.get(`.board-list-row:contains("${filterBoard.title}")`).should('be.visible')
cy.get(`.board-list-row:contains("${otherBoard.title}")`).should('be.visible')
})

it('Filters boards by title', function() {
cy.get('#deck-search-input').type('Unrelated')

cy.get(`.board-list-row:contains("${otherBoard.title}")`).should('be.visible')
cy.get(`.board-list-row:contains("${filterBoard.title}")`).should('not.exist')
})

it('Restores all boards when the filter is cleared', function() {
cy.get('#deck-search-input').type('Unrelated')
cy.get(`.board-list-row:contains("${filterBoard.title}")`).should('not.exist')

cy.get('.board-filter .input-field__trailing-button').click()

cy.get(`.board-list-row:contains("${filterBoard.title}")`).should('be.visible')
cy.get(`.board-list-row:contains("${otherBoard.title}")`).should('be.visible')
})
})

describe('On the upcoming overview', function() {
beforeEach(function() {
cy.login(user)
cy.visit('/apps/deck/#/upcoming')
cy.get('.controls').should('exist')
})

it('Has no filter input', function() {
cy.get('#deck-search-input').should('not.exist')
})

// This is the view that used to throw a TypeError on every Ctrl+F, because the
// input was never rendered anywhere. Cypress fails on uncaught exceptions.
it('Handles Ctrl+F without throwing when there is no filter', function() {
cy.get('body').type('{ctrl}f')

cy.get('.controls').should('be.visible')
})
})
})
95 changes: 71 additions & 24 deletions src/components/Controls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,6 @@
<div class="board-actions">
<SessionList v-if="isNotifyPushEnabled && presentUsers.length"
:sessions="presentUsers" />
<!-- Hide but not remove for now as search might change in the future -->
<div v-if="false" class="deck-search">
<input id="deck-search-input"
ref="search"
:tabindex="0"
type="search"
class="icon-search"
:value="searchQuery"
@focus="$store.dispatch('toggleShortcutLock', true)"
@blur="$store.dispatch('toggleShortcutLock', false)"
@input="$store.commit('setSearchQuery', $event.target.value)">
</div>
<div v-if="board && canManage && !showArchived && !board.archived"
id="stack-add"
v-click-outside="hideAddStack">
Expand Down Expand Up @@ -71,6 +59,24 @@
value="">
</form>
</div>
<template v-if="showFilter">
<!-- type="text", not "search": NcTextField only fills the trailing button's
icon slot when type !== 'search', so a search field renders the clear
button with no icon at all. "Filter" is also the better semantic here. -->
<NcTextField id="deck-search-input"
class="board-filter"
type="text"
:label="filterLabel"
:value="searchQuery"
:show-trailing-button="searchQuery !== ''"
:trailing-button-label="t('deck', 'Clear filter text')"
aria-describedby="deck-filter-hint"
@update:value="setSearchQuery"
@trailing-button-click="clearSearchQuery"
@focus="$store.dispatch('toggleShortcutLock', true)"
@blur="$store.dispatch('toggleShortcutLock', false)" />
<span id="deck-filter-hint" class="hidden-visually">{{ filterHint }}</span>
</template>
<div v-if="board" class="board-action-buttons">
<div class="board-action-buttons__filter">
<NcPopover :placement="'bottom-end'"
Expand Down Expand Up @@ -279,7 +285,7 @@
<script>
import { mapState, mapGetters } from 'vuex'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { NcActions, NcActionButton, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
import { NcActions, NcActionButton, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal, NcTextField } from '@nextcloud/vue'
import labelStyle from '../mixins/labelStyle.js'
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
import ImageIcon from 'vue-material-design-icons/ImageMultipleOutline.vue'
Expand All @@ -304,6 +310,7 @@ export default {
NcActionButton,
NcButton,
NcPopover,
NcTextField,
NcAvatar,
ArchiveIcon,
ImageIcon,
Expand Down Expand Up @@ -361,6 +368,23 @@ export default {
name: 'board.details',
}
},
// Controls is shared by the board, the board list and the overviews.
// Only the first two have something that consumes the query.
isBoardList() {
return !this.board && !this.overviewName
},
showFilter() {
return !!this.board || this.isBoardList
},
filterLabel() {
return this.isBoardList ? t('deck', 'Filter boards') : t('deck', 'Filter cards')
},
filterHint() {
// The prefixes are passed as a parameter so translators never see them as translatable text
return t('deck', 'Type to filter the current view. Supported prefixes: {prefixes}. Wrap phrases in double quotes.', {
prefixes: 'title:, description:, tag:, assigned:, list:, date:',
})
},
isFilterActive() {
return this.filter.tags.length !== 0 || this.filter.users.length !== 0 || this.filter.due !== '' || this.filter.completed !== 'both'
},
Expand Down Expand Up @@ -418,6 +442,12 @@ export default {
}
this.$nextTick(() => this.$store.dispatch('setFilter', { ...this.filter }))
},
setSearchQuery(value) {
this.$store.commit('setSearchQuery', value)
},
clearSearchQuery() {
this.$store.commit('setSearchQuery', '')
},
toggleNav() {
this.$store.dispatch('toggleNav')
},
Expand Down Expand Up @@ -486,9 +516,6 @@ export default {
triggerOpenFilters() {
this.$refs.filterPopover.$el.click()
},
triggerOpenSearch() {
this.$refs.search.focus()
},
triggerClearFilter() {
this.clearFilter()
},
Expand All @@ -505,20 +532,31 @@ export default {
</script>

<style lang="scss" scoped>
@import '../css/variables.scss';

.controls {
display: flex;
// Wrap so the filter can drop to its own row on narrow screens.
// This is why the height below is a min-height and not a height.
flex-wrap: wrap;
row-gap: var(--default-grid-baseline);
margin: calc(var(--default-grid-baseline) * 2);
height: var(--default-clickable-area);
min-height: var(--default-clickable-area);
padding-inline-start: var(--default-clickable-area);

.board-title {
display: flex;
align-items: center;
// A flex item holding text will not shrink below its content width without this
min-width: 0;

h2 {
margin: 0;
margin-inline-end: 10px;
font-size: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.board-bullet {
Expand Down Expand Up @@ -564,20 +602,29 @@ export default {
flex-grow: 1;
order: 100;
display: flex;
flex-wrap: wrap;
align-items: center;
row-gap: var(--default-grid-baseline);
justify-content: flex-end;
}

.board-action-buttons {
display: flex;
}

.deck-search {
display: flex;
align-items: center;
justify-content: center;
input[type=search] {
background-position: 5px;
padding-inline-start: 24px !important;
.board-filter {
flex: 0 1 15rem;
min-width: 0;
margin-inline-end: var(--default-grid-baseline);
}

@media (max-width: $breakpoint-small-mobile) {
// order sorts the filter after the buttons, which all default to 0,
// so it wraps onto its own row instead of pushing them down
.board-filter {
flex-basis: 100%;
order: 1;
margin-inline-end: 0;
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/components/KeyboardShortcuts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,16 @@ export default {
// Global shortcuts (not board specific)
if ((key.metaKey || key.ctrlKey) && key.code === 'KeyF') {
const searchInput = document.getElementById('deck-search-input')
// Views without a filter (the overviews) have no input to focus.
// Fall through so the browser's find-in-page still works there.
if (!searchInput) {
return
}
if (searchInput === document.activeElement) {
return false
}

document.getElementById('deck-search-input').focus()
searchInput.focus()
key.preventDefault()
return true
}
Expand Down
15 changes: 8 additions & 7 deletions src/css/variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
$card-min-width: 250px;
$card-max-width: 316px;
$card-padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
$card-gap: calc(var(--default-grid-baseline) * 3);
$card-image-margin: calc(var(--default-grid-baseline) * -2);
$stack-gap: calc(var(--default-grid-baseline) * 3);
$board-gap: calc(var(--default-grid-baseline) * 4);
$card-min-width: 250px;
$card-max-width: 316px;
$card-padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
$card-gap: calc(var(--default-grid-baseline) * 3);
$card-image-margin: calc(var(--default-grid-baseline) * -2);
$stack-gap: calc(var(--default-grid-baseline) * 3);
$board-gap: calc(var(--default-grid-baseline) * 4);
$breakpoint-small-mobile: 512px;
16 changes: 0 additions & 16 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import storeFactory from './store/main.js'
import { sync } from 'vuex-router-sync'
import { translate, translatePlural } from '@nextcloud/l10n'
import { showError } from '@nextcloud/dialogs'
import { subscribe } from '@nextcloud/event-bus'
import ClickOutside from 'vue-click-outside'
import './shared-init.js'
import './models/index.js'
Expand Down Expand Up @@ -62,28 +61,13 @@ new Vue({
}
},
created() {
subscribe('nextcloud:unified-search.search', ({ query }) => {
this.$store.commit('setSearchQuery', query)
})
subscribe('nextcloud:unified-search.reset', () => {
this.$store.commit('setSearchQuery', '')
})

this.interval = setInterval(() => {
this.time = Date.now()
}, 1000)
},
beforeDestroy() {
clearInterval(this.interval)
},
methods: {
filter(query) {
this.$store.commit('setSearchQuery', query)
},
cleanSearch() {
this.$store.commit('setSearchQuery', '')
},
},
render: h => h(App),
})

Expand Down
Loading