Skip to content

Commit 7174898

Browse files
committed
feat(deck): add board filter input to the board header
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent d5ccd0b commit 7174898

5 files changed

Lines changed: 214 additions & 48 deletions

File tree

cypress/e2e/boardFilter.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
import { randUser } from '../utils/index.js'
6+
7+
const user = randUser()
8+
9+
// sampleBoard() only ships a single card, so filtering would have nothing to discriminate
10+
const filterBoard = {
11+
title: 'FilterBoard',
12+
color: '00ff00',
13+
stacks: [
14+
{
15+
title: 'TestList',
16+
cards: [
17+
{ title: 'Alpha task' },
18+
{ title: 'Beta task' },
19+
{ title: 'Gamma thing' },
20+
],
21+
},
22+
],
23+
}
24+
25+
const otherBoard = {
26+
title: 'UnrelatedBoard',
27+
color: 'ff0000',
28+
stacks: [],
29+
}
30+
31+
describe('Board filter', function() {
32+
let boardId
33+
34+
before(function() {
35+
cy.createUser(user)
36+
cy.login(user)
37+
cy.createExampleBoard({ user, board: filterBoard }).then((board) => {
38+
boardId = board.id
39+
})
40+
cy.createExampleBoard({ user, board: otherBoard })
41+
})
42+
43+
describe('On a board', function() {
44+
beforeEach(function() {
45+
cy.login(user)
46+
cy.visit(`/apps/deck/#/board/${boardId}`)
47+
cy.get('.board .card').should('have.length', 3)
48+
})
49+
50+
it('Filters cards as you type', function() {
51+
cy.get('#deck-search-input').type('Alpha')
52+
53+
cy.get('.board .card').should('have.length', 1)
54+
cy.get('.board .card:contains("Alpha task")').should('be.visible')
55+
})
56+
57+
it('Restores all cards when the filter is cleared', function() {
58+
cy.get('#deck-search-input').type('Alpha')
59+
cy.get('.board .card').should('have.length', 1)
60+
61+
cy.get('.board-filter .input-field__trailing-button').click()
62+
63+
cy.get('#deck-search-input').should('have.value', '')
64+
cy.get('.board .card').should('have.length', 3)
65+
})
66+
67+
it('Supports the title: prefix', function() {
68+
cy.get('#deck-search-input').type('title:Gamma')
69+
70+
cy.get('.board .card').should('have.length', 1)
71+
cy.get('.board .card:contains("Gamma thing")').should('be.visible')
72+
})
73+
74+
// Deliberately not asserting that focus lands on the field: core's unified search
75+
// also binds Ctrl+F and only yields on paths listed in its appHandlesSearchShortcut,
76+
// so the outcome depends on the server version. What Deck owns is that the handler
77+
// no longer throws — it used to call .focus() on an element that was never rendered.
78+
// Cypress fails a test on any uncaught exception, so this assertion is implicit.
79+
it('Handles Ctrl+F without throwing', function() {
80+
cy.get('body').type('{ctrl}f')
81+
82+
cy.get('#deck-search-input').should('exist')
83+
})
84+
})
85+
86+
describe('On the board list', function() {
87+
beforeEach(function() {
88+
cy.login(user)
89+
cy.visit('/apps/deck/#/board')
90+
cy.get('.board-list-row:not(.board-list-header-row)').should('have.length', 2)
91+
})
92+
93+
it('Filters boards by title', function() {
94+
cy.get('#deck-search-input').type('Unrelated')
95+
96+
cy.get('.board-list-row:not(.board-list-header-row)').should('have.length', 1)
97+
cy.get('.board-list-row:contains("UnrelatedBoard")').should('be.visible')
98+
})
99+
100+
it('Restores all boards when the filter is cleared', function() {
101+
cy.get('#deck-search-input').type('Unrelated')
102+
cy.get('.board-list-row:not(.board-list-header-row)').should('have.length', 1)
103+
104+
cy.get('.board-filter .input-field__trailing-button').click()
105+
106+
cy.get('.board-list-row:not(.board-list-header-row)').should('have.length', 2)
107+
})
108+
})
109+
110+
describe('On the upcoming overview', function() {
111+
beforeEach(function() {
112+
cy.login(user)
113+
cy.visit('/apps/deck/#/upcoming')
114+
cy.get('.controls').should('exist')
115+
})
116+
117+
it('Has no filter input', function() {
118+
cy.get('#deck-search-input').should('not.exist')
119+
})
120+
121+
// This is the view that used to throw a TypeError on every Ctrl+F, because the
122+
// input was never rendered anywhere. Cypress fails on uncaught exceptions.
123+
it('Handles Ctrl+F without throwing when there is no filter', function() {
124+
cy.get('body').type('{ctrl}f')
125+
126+
cy.get('.controls').should('be.visible')
127+
})
128+
})
129+
})

src/components/Controls.vue

Lines changed: 71 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,6 @@
3131
<div class="board-actions">
3232
<SessionList v-if="isNotifyPushEnabled && presentUsers.length"
3333
:sessions="presentUsers" />
34-
<!-- Hide but not remove for now as search might change in the future -->
35-
<div v-if="false" class="deck-search">
36-
<input id="deck-search-input"
37-
ref="search"
38-
:tabindex="0"
39-
type="search"
40-
class="icon-search"
41-
:value="searchQuery"
42-
@focus="$store.dispatch('toggleShortcutLock', true)"
43-
@blur="$store.dispatch('toggleShortcutLock', false)"
44-
@input="$store.commit('setSearchQuery', $event.target.value)">
45-
</div>
4634
<div v-if="board && canManage && !showArchived && !board.archived"
4735
id="stack-add"
4836
v-click-outside="hideAddStack">
@@ -71,6 +59,24 @@
7159
value="">
7260
</form>
7361
</div>
62+
<template v-if="showFilter">
63+
<!-- type="text", not "search": NcTextField only fills the trailing button's
64+
icon slot when type !== 'search', so a search field renders the clear
65+
button with no icon at all. "Filter" is also the better semantic here. -->
66+
<NcTextField id="deck-search-input"
67+
class="board-filter"
68+
type="text"
69+
:label="filterLabel"
70+
:value="searchQuery"
71+
:show-trailing-button="searchQuery !== ''"
72+
:trailing-button-label="t('deck', 'Clear filter text')"
73+
aria-describedby="deck-filter-hint"
74+
@update:value="setSearchQuery"
75+
@trailing-button-click="clearSearchQuery"
76+
@focus="$store.dispatch('toggleShortcutLock', true)"
77+
@blur="$store.dispatch('toggleShortcutLock', false)" />
78+
<span id="deck-filter-hint" class="hidden-visually">{{ filterHint }}</span>
79+
</template>
7480
<div v-if="board" class="board-action-buttons">
7581
<div class="board-action-buttons__filter">
7682
<NcPopover :placement="'bottom-end'"
@@ -279,7 +285,7 @@
279285
<script>
280286
import { mapState, mapGetters } from 'vuex'
281287
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
282-
import { NcActions, NcActionButton, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
288+
import { NcActions, NcActionButton, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal, NcTextField } from '@nextcloud/vue'
283289
import labelStyle from '../mixins/labelStyle.js'
284290
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
285291
import ImageIcon from 'vue-material-design-icons/ImageMultipleOutline.vue'
@@ -304,6 +310,7 @@ export default {
304310
NcActionButton,
305311
NcButton,
306312
NcPopover,
313+
NcTextField,
307314
NcAvatar,
308315
ArchiveIcon,
309316
ImageIcon,
@@ -361,6 +368,23 @@ export default {
361368
name: 'board.details',
362369
}
363370
},
371+
// Controls is shared by the board, the board list and the overviews.
372+
// Only the first two have something that consumes the query.
373+
isBoardList() {
374+
return !this.board && !this.overviewName
375+
},
376+
showFilter() {
377+
return !!this.board || this.isBoardList
378+
},
379+
filterLabel() {
380+
return this.isBoardList ? t('deck', 'Filter boards') : t('deck', 'Filter cards')
381+
},
382+
filterHint() {
383+
// The prefixes are passed as a parameter so translators never see them as translatable text
384+
return t('deck', 'Type to filter the current view. Supported prefixes: {prefixes}. Wrap phrases in double quotes.', {
385+
prefixes: 'title:, description:, tag:, assigned:, list:, date:',
386+
})
387+
},
364388
isFilterActive() {
365389
return this.filter.tags.length !== 0 || this.filter.users.length !== 0 || this.filter.due !== '' || this.filter.completed !== 'both'
366390
},
@@ -418,6 +442,12 @@ export default {
418442
}
419443
this.$nextTick(() => this.$store.dispatch('setFilter', { ...this.filter }))
420444
},
445+
setSearchQuery(value) {
446+
this.$store.commit('setSearchQuery', value)
447+
},
448+
clearSearchQuery() {
449+
this.$store.commit('setSearchQuery', '')
450+
},
421451
toggleNav() {
422452
this.$store.dispatch('toggleNav')
423453
},
@@ -486,9 +516,6 @@ export default {
486516
triggerOpenFilters() {
487517
this.$refs.filterPopover.$el.click()
488518
},
489-
triggerOpenSearch() {
490-
this.$refs.search.focus()
491-
},
492519
triggerClearFilter() {
493520
this.clearFilter()
494521
},
@@ -505,20 +532,31 @@ export default {
505532
</script>
506533
507534
<style lang="scss" scoped>
535+
@import '../css/variables.scss';
536+
508537
.controls {
509538
display: flex;
539+
// Wrap so the filter can drop to its own row on narrow screens.
540+
// This is why the height below is a min-height and not a height.
541+
flex-wrap: wrap;
542+
row-gap: var(--default-grid-baseline);
510543
margin: calc(var(--default-grid-baseline) * 2);
511-
height: var(--default-clickable-area);
544+
min-height: var(--default-clickable-area);
512545
padding-inline-start: var(--default-clickable-area);
513546
514547
.board-title {
515548
display: flex;
516549
align-items: center;
550+
// A flex item holding text will not shrink below its content width without this
551+
min-width: 0;
517552
518553
h2 {
519554
margin: 0;
520555
margin-inline-end: 10px;
521556
font-size: 18px;
557+
overflow: hidden;
558+
text-overflow: ellipsis;
559+
white-space: nowrap;
522560
}
523561
524562
.board-bullet {
@@ -564,20 +602,29 @@ export default {
564602
flex-grow: 1;
565603
order: 100;
566604
display: flex;
605+
flex-wrap: wrap;
606+
align-items: center;
607+
row-gap: var(--default-grid-baseline);
567608
justify-content: flex-end;
568609
}
569610
570611
.board-action-buttons {
571612
display: flex;
572613
}
573614
574-
.deck-search {
575-
display: flex;
576-
align-items: center;
577-
justify-content: center;
578-
input[type=search] {
579-
background-position: 5px;
580-
padding-inline-start: 24px !important;
615+
.board-filter {
616+
flex: 0 1 15rem;
617+
min-width: 0;
618+
margin-inline-end: var(--default-grid-baseline);
619+
}
620+
621+
@media (max-width: $breakpoint-small-mobile) {
622+
// order sorts the filter after the buttons, which all default to 0,
623+
// so it wraps onto its own row instead of pushing them down
624+
.board-filter {
625+
flex-basis: 100%;
626+
order: 1;
627+
margin-inline-end: 0;
581628
}
582629
}
583630

src/components/KeyboardShortcuts.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,16 @@ export default {
7070
// Global shortcuts (not board specific)
7171
if ((key.metaKey || key.ctrlKey) && key.code === 'KeyF') {
7272
const searchInput = document.getElementById('deck-search-input')
73+
// Views without a filter (the overviews) have no input to focus.
74+
// Fall through so the browser's find-in-page still works there.
75+
if (!searchInput) {
76+
return
77+
}
7378
if (searchInput === document.activeElement) {
7479
return false
7580
}
7681
77-
document.getElementById('deck-search-input').focus()
82+
searchInput.focus()
7883
key.preventDefault()
7984
return true
8085
}

src/css/variables.scss

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
33
* SPDX-License-Identifier: AGPL-3.0-or-later
44
*/
5-
$card-min-width: 250px;
6-
$card-max-width: 316px;
7-
$card-padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
8-
$card-gap: calc(var(--default-grid-baseline) * 3);
9-
$card-image-margin: calc(var(--default-grid-baseline) * -2);
10-
$stack-gap: calc(var(--default-grid-baseline) * 3);
11-
$board-gap: calc(var(--default-grid-baseline) * 4);
5+
$card-min-width: 250px;
6+
$card-max-width: 316px;
7+
$card-padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
8+
$card-gap: calc(var(--default-grid-baseline) * 3);
9+
$card-image-margin: calc(var(--default-grid-baseline) * -2);
10+
$stack-gap: calc(var(--default-grid-baseline) * 3);
11+
$board-gap: calc(var(--default-grid-baseline) * 4);
12+
$breakpoint-small-mobile: 512px;

src/main.js

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import storeFactory from './store/main.js'
99
import { sync } from 'vuex-router-sync'
1010
import { translate, translatePlural } from '@nextcloud/l10n'
1111
import { showError } from '@nextcloud/dialogs'
12-
import { subscribe } from '@nextcloud/event-bus'
1312
import ClickOutside from 'vue-click-outside'
1413
import './shared-init.js'
1514
import './models/index.js'
@@ -62,28 +61,13 @@ new Vue({
6261
}
6362
},
6463
created() {
65-
subscribe('nextcloud:unified-search.search', ({ query }) => {
66-
this.$store.commit('setSearchQuery', query)
67-
})
68-
subscribe('nextcloud:unified-search.reset', () => {
69-
this.$store.commit('setSearchQuery', '')
70-
})
71-
7264
this.interval = setInterval(() => {
7365
this.time = Date.now()
7466
}, 1000)
7567
},
7668
beforeDestroy() {
7769
clearInterval(this.interval)
7870
},
79-
methods: {
80-
filter(query) {
81-
this.$store.commit('setSearchQuery', query)
82-
},
83-
cleanSearch() {
84-
this.$store.commit('setSearchQuery', '')
85-
},
86-
},
8771
render: h => h(App),
8872
})
8973

0 commit comments

Comments
 (0)