Skip to content
61 changes: 57 additions & 4 deletions src/components/Controls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@
:hideStatus="true" /> {{ user.displayname }}</label>
</div>

<h3>{{ t('deck', 'Filter by creator') }}</h3>
<div v-for="user in cardCreators" :key="user.uid" class="filter--item">
<input
:id="'creator-' + user.uid"
v-model="filter.creators"
type="checkbox"
class="checkbox"
:value="user.uid"
@change="setFilter">
<label :for="'creator-' + user.uid"><NcAvatar
:user="user.uid"
:size="24"
:disableMenu="true"
:hideStatus="true" /> {{ user.displayname }}</label>
</div>
<div v-if="hasDeletedCreators" class="filter--item">
<input
id="creator-deleted"
v-model="filter.creators"
type="checkbox"
class="checkbox"
:value="DELETED_CREATORS"
@change="setFilter">
<label for="creator-deleted"><AccountOffIcon :size="24" class="filter--deleted-icon" /> {{ t('deck', 'Deleted users') }}</label>
</div>

<h3>{{ t('deck', 'Filter by status') }}</h3>
<div class="filter--item">
<input
Expand Down Expand Up @@ -319,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'
Expand All @@ -330,6 +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, DELETED_CREATORS, isCreatorDeleted } from '../helpers/cardFilters.js'
import labelStyle from '../mixins/labelStyle.js'
import { isNotifyPushEnabled } from '../sessions.js'
import { useBoardStore } from '../stores/board.js'
Expand All @@ -341,6 +369,7 @@ export default {
name: 'Controls',
components: {
CreateNewCardCustomPicker,
AccountOffIcon,
NcModal,
NcActions,
NcActionButton,
Expand Down Expand Up @@ -398,10 +427,11 @@ export default {

data() {
return {
DELETED_CREATORS,
newStackTitle: '',
stack: '',
isAddStackVisible: false,
filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' },
filter: { tags: [], users: [], creators: [], due: '', unassigned: false, completed: 'both' },
showAddCardModal: false,
defaultPageTitle: false,
isNotifyPushEnabled: isNotifyPushEnabled(),
Expand All @@ -423,7 +453,24 @@ export default {
showCardCover: (state) => 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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions src/helpers/cardFilters.js
Original file line number Diff line number Diff line change
@@ -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))
}
106 changes: 106 additions & 0 deletions src/helpers/cardFilters.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
4 changes: 2 additions & 2 deletions src/stores/board.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', {}),
}),
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion src/stores/card.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down