From f6088ace996071e2cbb3b9476212e4bf4a4d3189 Mon Sep 17 00:00:00 2001 From: Simon Kern <14980558+skjerns@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:25:54 +0100 Subject: [PATCH 1/4] feat(filter): support filtering cards by creator in store Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Simon Kern <14980558+skjerns@users.noreply.github.com> --- src/helpers/cardFilters.js | 17 ++++++++++++++++ src/helpers/cardFilters.spec.js | 36 +++++++++++++++++++++++++++++++++ src/stores/board.js | 4 ++-- src/stores/card.js | 7 ++++++- 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/helpers/cardFilters.js create mode 100644 src/helpers/cardFilters.spec.js diff --git a/src/helpers/cardFilters.js b/src/helpers/cardFilters.js new file mode 100644 index 0000000000..ca628813cd --- /dev/null +++ b/src/helpers/cardFilters.js @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Check whether a card was created by one of the given users. + * The card owner is either a user id or a user object. + * + * @param {object} card the card to check + * @param {string[]} uids the user ids to match against + * @return {boolean} + */ +export function cardCreatedByAny(card, uids) { + const ownerUid = card.owner?.uid ?? card.owner + return uids.includes(ownerUid) +} diff --git a/src/helpers/cardFilters.spec.js b/src/helpers/cardFilters.spec.js new file mode 100644 index 0000000000..f8c760056a --- /dev/null +++ b/src/helpers/cardFilters.spec.js @@ -0,0 +1,36 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it } from '@jest/globals' +import { cardCreatedByAny } from './cardFilters.js' + +describe('cardCreatedByAny', () => { + it('matches an owner given as user id', () => { + expect(cardCreatedByAny({ owner: 'alice' }, ['alice'])).toBe(true) + }) + + it('matches an owner given as user object', () => { + expect(cardCreatedByAny({ owner: { uid: 'alice', displayname: 'Alice' } }, ['alice'])).toBe(true) + }) + + it('matches if the owner is any of several users', () => { + expect(cardCreatedByAny({ owner: 'bob' }, ['alice', 'bob'])).toBe(true) + expect(cardCreatedByAny({ owner: { uid: 'bob' } }, ['alice', 'bob'])).toBe(true) + }) + + it('does not match other owners', () => { + expect(cardCreatedByAny({ owner: 'carol' }, ['alice', 'bob'])).toBe(false) + expect(cardCreatedByAny({ owner: { uid: 'carol' } }, ['alice', 'bob'])).toBe(false) + }) + + it('does not match a card without owner', () => { + expect(cardCreatedByAny({ owner: null }, ['alice'])).toBe(false) + expect(cardCreatedByAny({}, ['alice'])).toBe(false) + }) + + it('does not match when no users are given', () => { + expect(cardCreatedByAny({ owner: 'alice' }, [])).toBe(false) + }) +}) diff --git a/src/stores/board.js b/src/stores/board.js index d0f2d2b25e..651fc006e0 100644 --- a/src/stores/board.js +++ b/src/stores/board.js @@ -25,7 +25,7 @@ export const useBoardStore = defineStore('board', { showArchived: false, viewModeByBoard: {}, assignableUsers: [], - filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' }, + filter: { tags: [], users: [], creators: [], due: '', unassigned: false, completed: 'both' }, boardFilter: BOARD_FILTERS.ALL, boards: loadState('deck', 'initialBoards', {}), }), @@ -145,7 +145,7 @@ export const useBoardStore = defineStore('board', { localStorage.setItem(`deck.viewMode.${this.currentBoard.id}`, mode) }, async loadBoardById(boardId) { - this.filter = { tags: [], users: [], due: '', unassigned: false, completed: 'both' } + this.filter = { tags: [], users: [], creators: [], due: '', unassigned: false, completed: 'both' } this.setCurrentBoard(null) const board = await apiClient.loadById(boardId) this.setCurrentBoard(board) diff --git a/src/stores/card.js b/src/stores/card.js index 666b042a0e..38a090e766 100644 --- a/src/stores/card.js +++ b/src/stores/card.js @@ -5,6 +5,7 @@ import moment from 'moment' import { defineStore } from 'pinia' +import { cardCreatedByAny } from '../helpers/cardFilters.js' import { CardApi } from '../services/CardApi.js' import { useBoardStore } from './board.js' import { useSettingsStore } from './settings.js' @@ -20,7 +21,7 @@ export const useCardStore = defineStore('card', { getters: { cardsByStack(state) { return (id) => state.cards.filter((card) => { - const { tags, users, due, unassigned, completed } = useBoardStore().filter + const { tags, users, creators, due, unassigned, completed } = useBoardStore().filter if (completed === 'open' && card.done !== null) { return false } @@ -49,6 +50,10 @@ export const useCardStore = defineStore('card', { } } + if (creators.length > 0 && !cardCreatedByAny(card, creators)) { + return false + } + if (unassigned && card.assignedUsers.length > 0) { return false } From 26f5681d22a72e2ed77092b1042d31a2fc9c3d96 Mon Sep 17 00:00:00 2001 From: Simon Kern <14980558+skjerns@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:25:54 +0100 Subject: [PATCH 2/4] feat(filter): add "Filter by creator" section to board filter Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Simon Kern <14980558+skjerns@users.noreply.github.com> --- src/components/Controls.vue | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/Controls.vue b/src/components/Controls.vue index 2c09e1fbe7..b0e4febb9a 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -146,6 +146,22 @@ :hideStatus="true" /> {{ user.displayname }} +

{{ t('deck', 'Filter by creator') }}

+
+ + +
+

{{ t('deck', 'Filter by status') }}

Date: Mon, 9 Nov 2026 19:44:21 +0100 Subject: [PATCH 3/4] fix(filter): list all card creators in creator filter Include creators who no longer have access to the board, and skip removed users. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Simon Kern <14980558+skjerns@users.noreply.github.com> --- src/components/Controls.vue | 15 +++++++++++++-- src/helpers/cardFilters.js | 20 ++++++++++++++++++++ src/helpers/cardFilters.spec.js | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/components/Controls.vue b/src/components/Controls.vue index b0e4febb9a..570f02996d 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -147,7 +147,7 @@

{{ t('deck', 'Filter by creator') }}

-
+
state.showCardCover, }), - ...mapState(useCardStore, ['cardById']), + ...mapState(useCardStore, ['cardById', 'cards']), + ...mapState(useStackStore, ['stacksByBoard']), + cardCreators() { + if (!this.board) { + return [] + } + const stackIds = new Set(this.stacksByBoard(this.board.id).map((stack) => stack.id)) + const boardCards = this.cards.filter((card) => stackIds.has(card.stackId)) + return cardCreatorUsers(boardCards, this.board.users) + }, + ...mapWritableState(useSettingsStore, ['searchQuery']), pageTitle() { if (!this.board?.title) { diff --git a/src/helpers/cardFilters.js b/src/helpers/cardFilters.js index ca628813cd..b6dcc2bf20 100644 --- a/src/helpers/cardFilters.js +++ b/src/helpers/cardFilters.js @@ -15,3 +15,23 @@ export function cardCreatedByAny(card, uids) { const ownerUid = card.owner?.uid ?? card.owner return uids.includes(ownerUid) } + +/** + * Collect the users that can be picked in the "Filter by creator" list: + * the creators of the given cards merged with the given board users, + * de-duplicated by uid and sorted by display name. Users without a + * display name (e.g. removed accounts) are skipped. + * + * @param {object[]} cards the cards of the board + * @param {object[]} boardUsers the users the board is shared with + * @return {object[]} + */ +export function cardCreatorUsers(cards, boardUsers = []) { + const users = new Map() + for (const user of [...boardUsers, ...cards.map((card) => card.owner)]) { + if (user?.uid && user.displayname && !users.has(user.uid)) { + users.set(user.uid, user) + } + } + return [...users.values()].sort((a, b) => a.displayname.localeCompare(b.displayname)) +} diff --git a/src/helpers/cardFilters.spec.js b/src/helpers/cardFilters.spec.js index f8c760056a..777ddf0a4a 100644 --- a/src/helpers/cardFilters.spec.js +++ b/src/helpers/cardFilters.spec.js @@ -4,7 +4,7 @@ */ import { describe, expect, it } from '@jest/globals' -import { cardCreatedByAny } from './cardFilters.js' +import { cardCreatedByAny, cardCreatorUsers } from './cardFilters.js' describe('cardCreatedByAny', () => { it('matches an owner given as user id', () => { @@ -34,3 +34,34 @@ describe('cardCreatedByAny', () => { expect(cardCreatedByAny({ owner: 'alice' }, [])).toBe(false) }) }) + +describe('cardCreatorUsers', () => { + const alice = { uid: 'alice', displayname: 'Alice' } + const bob = { uid: 'bob', displayname: 'Bob' } + const carol = { uid: 'carol', displayname: 'Carol' } + + it('includes creators that are no longer board users', () => { + const cards = [{ owner: carol }, { owner: alice }] + expect(cardCreatorUsers(cards, [alice, bob])).toEqual([alice, bob, carol]) + }) + + it('de-duplicates users by uid', () => { + const cards = [{ owner: { ...alice } }, { owner: alice }, { owner: bob }] + expect(cardCreatorUsers(cards, [alice])).toEqual([alice, bob]) + }) + + it('sorts users by display name', () => { + const cards = [{ owner: carol }, { owner: bob }, { owner: alice }] + expect(cardCreatorUsers(cards).map((user) => user.uid)).toEqual(['alice', 'bob', 'carol']) + }) + + it('skips removed users without display name', () => { + const cards = [{ owner: { uid: 'removed', displayname: null } }, { owner: alice }] + expect(cardCreatorUsers(cards, [])).toEqual([alice]) + }) + + it('skips cards without owner object', () => { + const cards = [{ owner: null }, {}, { owner: 'bob' }] + expect(cardCreatorUsers(cards, [alice])).toEqual([alice]) + }) +}) From 9b7d03f3c9243b912d5c9d78cfd7c8aa0c118212 Mon Sep 17 00:00:00 2001 From: Simon Kern <14980558+skjerns@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:18:04 +0100 Subject: [PATCH 4/4] feat(filter): group deleted accounts as "Deleted users" in creator filter Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Simon Kern <14980558+skjerns@users.noreply.github.com> --- src/components/Controls.vue | 34 +++++++++++++++++++++++---- src/helpers/cardFilters.js | 40 ++++++++++++++++++++++++++++---- src/helpers/cardFilters.spec.js | 41 ++++++++++++++++++++++++++++++++- src/stores/card.js | 2 +- 4 files changed, 107 insertions(+), 10 deletions(-) diff --git a/src/components/Controls.vue b/src/components/Controls.vue index 570f02996d..829bb64c83 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -161,6 +161,16 @@ :disableMenu="true" :hideStatus="true" /> {{ user.displayname }}
+
+ + +

{{ t('deck', 'Filter by status') }}

@@ -335,6 +345,7 @@ import { subscribe, unsubscribe } from '@nextcloud/event-bus' import { NcActionButton, NcActions, NcActionSeparator, NcAvatar, NcButton, NcModal, NcPopover, NcTextField } from '@nextcloud/vue' import { vOnClickOutside } from '@vueuse/components' import { mapActions, mapState, mapWritableState } from 'pinia' +import AccountOffIcon from 'vue-material-design-icons/AccountOffOutline.vue' import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue' import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue' import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue' @@ -346,7 +357,7 @@ import TableColumnPlusAfter from 'vue-material-design-icons/TableColumnPlusAfter import ViewColumnIcon from 'vue-material-design-icons/ViewColumn.vue' import CreateNewCardCustomPicker from '../views/CreateNewCardCustomPicker.vue' import SessionList from './SessionList.vue' -import { cardCreatorUsers } from '../helpers/cardFilters.js' +import { cardCreatorUsers, DELETED_CREATORS, isCreatorDeleted } from '../helpers/cardFilters.js' import labelStyle from '../mixins/labelStyle.js' import { isNotifyPushEnabled } from '../sessions.js' import { useBoardStore } from '../stores/board.js' @@ -358,6 +369,7 @@ export default { name: 'Controls', components: { CreateNewCardCustomPicker, + AccountOffIcon, NcModal, NcActions, NcActionButton, @@ -415,6 +427,7 @@ export default { data() { return { + DELETED_CREATORS, newStackTitle: '', stack: '', isAddStackVisible: false, @@ -442,13 +455,20 @@ export default { ...mapState(useCardStore, ['cardById', 'cards']), ...mapState(useStackStore, ['stacksByBoard']), - cardCreators() { + boardCards() { if (!this.board) { return [] } const stackIds = new Set(this.stacksByBoard(this.board.id).map((stack) => stack.id)) - const boardCards = this.cards.filter((card) => stackIds.has(card.stackId)) - return cardCreatorUsers(boardCards, this.board.users) + return this.cards.filter((card) => stackIds.has(card.stackId)) + }, + + cardCreators() { + return cardCreatorUsers(this.boardCards, this.board?.users) + }, + + hasDeletedCreators() { + return this.boardCards.some((card) => isCreatorDeleted(card, this.board.users)) }, ...mapWritableState(useSettingsStore, ['searchQuery']), @@ -753,6 +773,12 @@ export default { margin-bottom: 2px; margin-inline-end: 3px; } + .filter--deleted-icon { + display: inline-flex; + vertical-align: middle; + margin-bottom: 2px; + margin-inline-end: 3px; + } .label { padding: 5px; border-radius: 3px; diff --git a/src/helpers/cardFilters.js b/src/helpers/cardFilters.js index b6dcc2bf20..9247f0e3de 100644 --- a/src/helpers/cardFilters.js +++ b/src/helpers/cardFilters.js @@ -3,15 +3,47 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +/** + * Filter value standing for all cards created by deleted accounts. + * Contains a colon, which is not allowed in Nextcloud user ids. + */ +export const DELETED_CREATORS = 'deck:deleted-users' + +/** + * Check whether a card was created by an account that no longer exists. + * The server sends the owner as a user object only for existing accounts, + * deleted ones come as plain uid. A plain uid of a board user (e.g. a card + * just created in this session) does not count as deleted. + * + * @param {object} card the card to check + * @param {object[]} boardUsers the users with access to the board + * @return {boolean} + */ +export function isCreatorDeleted(card, boardUsers = []) { + const owner = card.owner + if (!owner) { + return false + } + if (typeof owner === 'string') { + return !boardUsers.some((user) => user.uid === owner) + } + return !owner.displayname +} + /** * Check whether a card was created by one of the given users. - * The card owner is either a user id or a user object. + * The card owner is either a user id or a user object. The value + * DELETED_CREATORS matches cards of all deleted accounts. * * @param {object} card the card to check * @param {string[]} uids the user ids to match against + * @param {object[]} boardUsers the users with access to the board, needed for DELETED_CREATORS * @return {boolean} */ -export function cardCreatedByAny(card, uids) { +export function cardCreatedByAny(card, uids, boardUsers = []) { + if (uids.includes(DELETED_CREATORS) && isCreatorDeleted(card, boardUsers)) { + return true + } const ownerUid = card.owner?.uid ?? card.owner return uids.includes(ownerUid) } @@ -19,8 +51,8 @@ export function cardCreatedByAny(card, uids) { /** * Collect the users that can be picked in the "Filter by creator" list: * the creators of the given cards merged with the given board users, - * de-duplicated by uid and sorted by display name. Users without a - * display name (e.g. removed accounts) are skipped. + * de-duplicated by uid and sorted by display name. Deleted accounts are + * not listed, see isCreatorDeleted() for grouping them. * * @param {object[]} cards the cards of the board * @param {object[]} boardUsers the users the board is shared with diff --git a/src/helpers/cardFilters.spec.js b/src/helpers/cardFilters.spec.js index 777ddf0a4a..e7cae3aa3b 100644 --- a/src/helpers/cardFilters.spec.js +++ b/src/helpers/cardFilters.spec.js @@ -4,7 +4,7 @@ */ import { describe, expect, it } from '@jest/globals' -import { cardCreatedByAny, cardCreatorUsers } from './cardFilters.js' +import { cardCreatedByAny, cardCreatorUsers, DELETED_CREATORS, isCreatorDeleted } from './cardFilters.js' describe('cardCreatedByAny', () => { it('matches an owner given as user id', () => { @@ -65,3 +65,42 @@ describe('cardCreatorUsers', () => { expect(cardCreatorUsers(cards, [alice])).toEqual([alice]) }) }) + +describe('deleted creators', () => { + const boardUsers = [{ uid: 'alice', displayname: 'Alice' }] + + it('treats a plain uid owner outside the board users as deleted', () => { + expect(isCreatorDeleted({ owner: 'gone' }, boardUsers)).toBe(true) + }) + + it('does not treat a plain uid owner of a board user as deleted', () => { + expect(isCreatorDeleted({ owner: 'alice' }, boardUsers)).toBe(false) + }) + + it('does not treat resolved owners as deleted, even without board access', () => { + expect(isCreatorDeleted({ owner: { uid: 'bob', displayname: 'Bob' } }, boardUsers)).toBe(false) + }) + + it('treats a resolved owner without display name as deleted', () => { + expect(isCreatorDeleted({ owner: { uid: 'gone', displayname: null } }, boardUsers)).toBe(true) + }) + + it('does not treat cards without owner as deleted', () => { + expect(isCreatorDeleted({ owner: null }, boardUsers)).toBe(false) + expect(isCreatorDeleted({}, boardUsers)).toBe(false) + }) + + it('matches all deleted creators with the DELETED_CREATORS value', () => { + expect(cardCreatedByAny({ owner: 'gone' }, [DELETED_CREATORS], boardUsers)).toBe(true) + expect(cardCreatedByAny({ owner: 'other-gone' }, [DELETED_CREATORS], boardUsers)).toBe(true) + expect(cardCreatedByAny({ owner: 'alice' }, [DELETED_CREATORS], boardUsers)).toBe(false) + expect(cardCreatedByAny({ owner: { uid: 'bob', displayname: 'Bob' } }, [DELETED_CREATORS], boardUsers)).toBe(false) + }) + + it('combines DELETED_CREATORS with selected users', () => { + const uids = [DELETED_CREATORS, 'bob'] + expect(cardCreatedByAny({ owner: 'gone' }, uids, boardUsers)).toBe(true) + expect(cardCreatedByAny({ owner: { uid: 'bob', displayname: 'Bob' } }, uids, boardUsers)).toBe(true) + expect(cardCreatedByAny({ owner: 'alice' }, uids, boardUsers)).toBe(false) + }) +}) diff --git a/src/stores/card.js b/src/stores/card.js index 38a090e766..3c16e14bf5 100644 --- a/src/stores/card.js +++ b/src/stores/card.js @@ -50,7 +50,7 @@ export const useCardStore = defineStore('card', { } } - if (creators.length > 0 && !cardCreatedByAny(card, creators)) { + if (creators.length > 0 && !cardCreatedByAny(card, creators, useBoardStore().currentBoard?.users)) { return false }