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 }