diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index 27768109c5..e8e532862f 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -146,6 +146,32 @@
:hideStatus="true" /> {{ user.displayname }}
+
{{ t('deck', 'Filter by creator') }}
+
+
+
+
+
+
+
+
+
{{ t('deck', 'Filter by status') }}
state.showCardCover,
}),
- ...mapState(useCardStore, ['cardById']),
+ ...mapState(useCardStore, ['cardById', 'cards']),
+ ...mapState(useStackStore, ['stacksByBoard']),
+ boardCards() {
+ if (!this.board) {
+ return []
+ }
+ const stackIds = new Set(this.stacksByBoard(this.board.id).map((stack) => stack.id))
+ 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']),
pageTitle() {
if (!this.isFullApp || !this.board?.title) {
@@ -440,7 +487,7 @@ export default {
},
isFilterActive() {
- return this.filter.tags.length !== 0 || this.filter.users.length !== 0 || this.filter.due !== '' || this.filter.completed !== 'both'
+ return this.filter.tags.length !== 0 || this.filter.users.length !== 0 || this.filter.creators.length !== 0 || this.filter.due !== '' || this.filter.completed !== 'both'
},
labelsSorted() {
@@ -567,7 +614,7 @@ export default {
},
clearFilter() {
- const filterReset = { tags: [], users: [], due: '', unassigned: false, completed: 'both' }
+ const filterReset = { tags: [], users: [], creators: [], due: '', unassigned: false, completed: 'both' }
this.setFilterInStore({ ...filterReset })
this.filter = filterReset
},
@@ -726,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
new file mode 100644
index 0000000000..9247f0e3de
--- /dev/null
+++ b/src/helpers/cardFilters.js
@@ -0,0 +1,69 @@
+/**
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
+ * 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 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, boardUsers = []) {
+ if (uids.includes(DELETED_CREATORS) && isCreatorDeleted(card, boardUsers)) {
+ return true
+ }
+ 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. 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
+ * @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
new file mode 100644
index 0000000000..e7cae3aa3b
--- /dev/null
+++ b/src/helpers/cardFilters.spec.js
@@ -0,0 +1,106 @@
+/**
+ * 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, cardCreatorUsers, DELETED_CREATORS, isCreatorDeleted } 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)
+ })
+})
+
+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])
+ })
+})
+
+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/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..3c16e14bf5 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, useBoardStore().currentBoard?.users)) {
+ return false
+ }
+
if (unassigned && card.assignedUsers.length > 0) {
return false
}