Skip to content

Commit f19f317

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 f19f317

7 files changed

Lines changed: 223 additions & 50 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 one card, so searching 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-search .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+
// Not asserting where focus lands: core's unified search also claims Ctrl+F unless
75+
// the path is in its appHandlesSearchShortcut list, so that depends on the server
76+
// version. Deck owns only that this no longer throws, which Cypress checks for us.
77+
it('Handles Ctrl+F without throwing', function() {
78+
cy.get('body').type('{ctrl}f')
79+
80+
cy.get('#deck-search-input').should('exist')
81+
})
82+
})
83+
84+
describe('On the board list', function() {
85+
// Assert on specific boards, not a total: new users also get a default board
86+
beforeEach(function() {
87+
cy.login(user)
88+
cy.visit('/apps/deck/#/board')
89+
cy.get(`.board-list-row:contains("${filterBoard.title}")`).should('be.visible')
90+
cy.get(`.board-list-row:contains("${otherBoard.title}")`).should('be.visible')
91+
})
92+
93+
it('Filters boards by title', function() {
94+
cy.get('#deck-search-input').type('Unrelated')
95+
96+
cy.get(`.board-list-row:contains("${otherBoard.title}")`).should('be.visible')
97+
cy.get(`.board-list-row:contains("${filterBoard.title}")`).should('not.exist')
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:contains("${filterBoard.title}")`).should('not.exist')
103+
104+
cy.get('.board-search .input-field__trailing-button').click()
105+
106+
cy.get(`.board-list-row:contains("${filterBoard.title}")`).should('be.visible')
107+
cy.get(`.board-list-row:contains("${otherBoard.title}")`).should('be.visible')
108+
})
109+
})
110+
111+
describe('On the upcoming overview', function() {
112+
beforeEach(function() {
113+
cy.login(user)
114+
cy.visit('/apps/deck/#/upcoming')
115+
cy.get('.controls').should('exist')
116+
})
117+
118+
it('Has no filter input', function() {
119+
cy.get('#deck-search-input').should('not.exist')
120+
})
121+
122+
// The view that used to throw a TypeError on every Ctrl+F
123+
it('Handles Ctrl+F without throwing when there is no search field', 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: 69 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,26 @@
7159
value="">
7260
</form>
7361
</div>
62+
<template v-if="showSearch">
63+
<!-- Not type="search": NcTextField only fills the trailing button's icon
64+
slot when type !== 'search', which leaves the clear button iconless. -->
65+
<NcTextField id="deck-search-input"
66+
class="board-search"
67+
type="text"
68+
:label="searchLabel"
69+
:value="searchQuery"
70+
:title="searchHint || null"
71+
:show-trailing-button="searchQuery !== ''"
72+
:trailing-button-label="t('deck', 'Clear search')"
73+
:aria-describedby="searchHint ? 'deck-search-hint' : null"
74+
@update:value="setSearchQuery"
75+
@trailing-button-click="clearSearchQuery"
76+
@focus="$store.dispatch('toggleShortcutLock', true)"
77+
@blur="$store.dispatch('toggleShortcutLock', false)" />
78+
<!-- title is for pointer users, aria-describedby for assistive tech. No double
79+
announcement: title is only the fallback description per HTML-AAM. -->
80+
<span v-if="searchHint" id="deck-search-hint" class="hidden-visually">{{ searchHint }}</span>
81+
</template>
7482
<div v-if="board" class="board-action-buttons">
7583
<div class="board-action-buttons__filter">
7684
<NcPopover :placement="'bottom-end'"
@@ -279,7 +287,7 @@
279287
<script>
280288
import { mapState, mapGetters } from 'vuex'
281289
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
282-
import { NcActions, NcActionButton, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal } from '@nextcloud/vue'
290+
import { NcActions, NcActionButton, NcActionSeparator, NcAvatar, NcButton, NcPopover, NcModal, NcTextField } from '@nextcloud/vue'
283291
import labelStyle from '../mixins/labelStyle.js'
284292
import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
285293
import ImageIcon from 'vue-material-design-icons/ImageMultipleOutline.vue'
@@ -304,6 +312,7 @@ export default {
304312
NcActionButton,
305313
NcButton,
306314
NcPopover,
315+
NcTextField,
307316
NcAvatar,
308317
ArchiveIcon,
309318
ImageIcon,
@@ -329,6 +338,19 @@ export default {
329338
required: false,
330339
default: null,
331340
},
341+
showSearch: {
342+
type: Boolean,
343+
default: false,
344+
},
345+
searchLabel: {
346+
type: String,
347+
default: '',
348+
},
349+
// Only pass this where the card prefixes actually apply
350+
searchHint: {
351+
type: String,
352+
default: '',
353+
},
332354
},
333355
data() {
334356
return {
@@ -418,6 +440,12 @@ export default {
418440
}
419441
this.$nextTick(() => this.$store.dispatch('setFilter', { ...this.filter }))
420442
},
443+
setSearchQuery(value) {
444+
this.$store.commit('setSearchQuery', value)
445+
},
446+
clearSearchQuery() {
447+
this.$store.commit('setSearchQuery', '')
448+
},
421449
toggleNav() {
422450
this.$store.dispatch('toggleNav')
423451
},
@@ -486,9 +514,6 @@ export default {
486514
triggerOpenFilters() {
487515
this.$refs.filterPopover.$el.click()
488516
},
489-
triggerOpenSearch() {
490-
this.$refs.search.focus()
491-
},
492517
triggerClearFilter() {
493518
this.clearFilter()
494519
},
@@ -505,20 +530,30 @@ export default {
505530
</script>
506531
507532
<style lang="scss" scoped>
533+
@import '../css/variables.scss';
534+
508535
.controls {
509536
display: flex;
537+
// min-height, not height: the search wraps to a second row on narrow screens
538+
flex-wrap: wrap;
539+
row-gap: var(--default-grid-baseline);
510540
margin: calc(var(--default-grid-baseline) * 2);
511-
height: var(--default-clickable-area);
541+
min-height: var(--default-clickable-area);
512542
padding-inline-start: var(--default-clickable-area);
513543
514544
.board-title {
515545
display: flex;
516546
align-items: center;
547+
// lets the h2 below actually truncate
548+
min-width: 0;
517549
518550
h2 {
519551
margin: 0;
520552
margin-inline-end: 10px;
521553
font-size: 18px;
554+
overflow: hidden;
555+
text-overflow: ellipsis;
556+
white-space: nowrap;
522557
}
523558
524559
.board-bullet {
@@ -564,20 +599,30 @@ export default {
564599
flex-grow: 1;
565600
order: 100;
566601
display: flex;
602+
flex-wrap: wrap;
603+
align-items: center;
604+
row-gap: var(--default-grid-baseline);
567605
justify-content: flex-end;
568606
}
569607
570608
.board-action-buttons {
571609
display: flex;
572610
}
573611
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;
612+
.board-search {
613+
flex: 0 1 15rem;
614+
min-width: 0;
615+
margin-inline-end: var(--default-grid-baseline);
616+
}
617+
618+
@media (max-width: $breakpoint-small-mobile) {
619+
// Own row below the buttons, spanning the full header. The negative margin cancels
620+
// the padding .controls reserves for the navigation toggle, which only occupies the
621+
// first row; the oversized basis keeps the search alone on its line, so it is safe.
622+
.board-search {
623+
order: 1;
624+
flex-basis: calc(100% + var(--default-clickable-area));
625+
margin-inline: calc(-1 * var(--default-clickable-area)) 0;
581626
}
582627
}
583628

src/components/KeyboardShortcuts.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,15 @@ 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+
// Overviews have no search field, so leave Ctrl+F to the browser there
74+
if (!searchInput) {
75+
return
76+
}
7377
if (searchInput === document.activeElement) {
7478
return false
7579
}
7680
77-
document.getElementById('deck-search-input').focus()
81+
searchInput.focus()
7882
key.preventDefault()
7983
return true
8084
}

src/components/board/Board.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
<template>
77
<div class="board-wrapper" :tabindex="-1" @touchend="fixActionRestriction">
8-
<Controls :board="board" />
8+
<Controls :board="board"
9+
show-search
10+
:search-label="t('deck', 'Search cards')"
11+
:search-hint="searchHint" />
912

1013
<transition name="fade" mode="out-in">
1114
<div v-if="loading" key="loading" class="emptycontent">
@@ -149,6 +152,12 @@ export default {
149152
stacksByBoard() {
150153
return this.board?.id ? this.$store.getters.stacksByBoard(this.board.id) : []
151154
},
155+
searchHint() {
156+
// Parameterised so translators never see the prefixes as translatable text
157+
return t('deck', 'Supported prefixes: {prefixes}. Wrap phrases in double quotes.', {
158+
prefixes: 'title:, description:, tag:, assigned:, list:, date:',
159+
})
160+
},
152161
dragHandleSelector() {
153162
return this.canEdit ? '.stack__title' : '.no-drag'
154163
},

src/components/boards/Boards.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
<template>
77
<div>
8-
<Controls />
8+
<!-- No hint: this matches plain titles, the card prefixes do not apply here -->
9+
<Controls show-search :search-label="t('deck', 'Search boards')" />
910
<div class="board-list">
1011
<div class="board-list-row board-list-header-row">
1112
<div class="board-list-bullet-cell">

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;

0 commit comments

Comments
 (0)