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
129 changes: 129 additions & 0 deletions cypress/e2e/boardFilter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* 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 one card, so searching 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-search .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')
})

// Not asserting where focus lands: core's unified search also claims Ctrl+F unless
// the path is in its appHandlesSearchShortcut list, so that depends on the server
// version. Deck owns only that this no longer throws, which Cypress checks for us.
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 specific boards, not a total: new users also get a default board
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-search .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')
})

// The view that used to throw a TypeError on every Ctrl+F
it('Handles Ctrl+F without throwing when there is no search field', function() {
cy.get('body').type('{ctrl}f')

cy.get('.controls').should('be.visible')
})
})
})
93 changes: 69 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,26 @@
value="">
</form>
</div>
<template v-if="showSearch">
<!-- Not type="search": NcTextField only fills the trailing button's icon
slot when type !== 'search', which leaves the clear button iconless. -->
<NcTextField id="deck-search-input"
class="board-search"
type="text"
:label="searchLabel"
:value="searchQuery"
:title="searchHint || null"
:show-trailing-button="searchQuery !== ''"
:trailing-button-label="t('deck', 'Clear search')"
:aria-describedby="searchHint ? 'deck-search-hint' : null"
@update:value="setSearchQuery"
@trailing-button-click="clearSearchQuery"
@focus="$store.dispatch('toggleShortcutLock', true)"
@blur="$store.dispatch('toggleShortcutLock', false)" />
<!-- title is for pointer users, aria-describedby for assistive tech. No double
announcement: title is only the fallback description per HTML-AAM. -->
<span v-if="searchHint" id="deck-search-hint" class="hidden-visually">{{ searchHint }}</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 +287,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 +312,7 @@ export default {
NcActionButton,
NcButton,
NcPopover,
NcTextField,
NcAvatar,
ArchiveIcon,
ImageIcon,
Expand All @@ -329,6 +338,19 @@ export default {
required: false,
default: null,
},
showSearch: {
type: Boolean,
default: false,
},
searchLabel: {
type: String,
default: '',
},
// Only pass this where the card prefixes actually apply
searchHint: {
type: String,
default: '',
},
},
data() {
return {
Expand Down Expand Up @@ -418,6 +440,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 +514,6 @@ export default {
triggerOpenFilters() {
this.$refs.filterPopover.$el.click()
},
triggerOpenSearch() {
this.$refs.search.focus()
},
triggerClearFilter() {
this.clearFilter()
},
Expand All @@ -505,20 +530,30 @@ export default {
</script>

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

.controls {
display: flex;
// min-height, not height: the search wraps to a second row on narrow screens
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;
// lets the h2 below actually truncate
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 +599,30 @@ 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-search {
flex: 0 1 15rem;
min-width: 0;
margin-inline-end: var(--default-grid-baseline);
}

@media (max-width: $breakpoint-small-mobile) {
// Own row below the buttons, spanning the full header. The negative margin cancels
// the padding .controls reserves for the navigation toggle, which only occupies the
// first row; the oversized basis keeps the search alone on its line, so it is safe.
.board-search {
order: 1;
flex-basis: calc(100% + var(--default-clickable-area));
margin-inline: calc(-1 * var(--default-clickable-area)) 0;
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/components/KeyboardShortcuts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,15 @@ export default {
// Global shortcuts (not board specific)
if ((key.metaKey || key.ctrlKey) && key.code === 'KeyF') {
const searchInput = document.getElementById('deck-search-input')
// Overviews have no search field, so leave Ctrl+F to the browser there
if (!searchInput) {
return
}
if (searchInput === document.activeElement) {
return false
}

document.getElementById('deck-search-input').focus()
searchInput.focus()
key.preventDefault()
return true
}
Expand Down
11 changes: 10 additions & 1 deletion src/components/board/Board.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@

<template>
<div class="board-wrapper" :tabindex="-1" @touchend="fixActionRestriction">
<Controls :board="board" />
<Controls :board="board"
show-search
:search-label="t('deck', 'Search cards')"
:search-hint="searchHint" />

<transition name="fade" mode="out-in">
<div v-if="loading" key="loading" class="emptycontent">
Expand Down Expand Up @@ -149,6 +152,12 @@ export default {
stacksByBoard() {
return this.board?.id ? this.$store.getters.stacksByBoard(this.board.id) : []
},
searchHint() {
// Parameterised so translators never see the prefixes as translatable text
return t('deck', 'Supported prefixes: {prefixes}. Wrap phrases in double quotes.', {
prefixes: 'title:, description:, tag:, assigned:, list:, date:',
})
},
dragHandleSelector() {
return this.canEdit ? '.stack__title' : '.no-drag'
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/boards/Boards.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

<template>
<div>
<Controls />
<!-- No hint: this matches plain titles, the card prefixes do not apply here -->
<Controls show-search :search-label="t('deck', 'Search boards')" />
<div class="board-list">
<div class="board-list-row board-list-header-row">
<div class="board-list-bullet-cell">
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;
Loading
Loading