diff --git a/appinfo/routes.php b/appinfo/routes.php
index 8a2b9be5f..0df5b2244 100644
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -144,6 +144,12 @@
['name' => 'board_ocs#createForTeam', 'url' => '/api/v{apiVersion}/boards/team', 'verb' => 'POST'],
['name' => 'board_ocs#addAcl', 'url' => '/api/v{apiVersion}/boards/{boardId}/acl', 'verb' => 'POST'],
+ ['name' => 'board_view_api#all', 'url' => '/api/v{apiVersion}/views', 'verb' => 'GET'],
+ ['name' => 'board_view_api#index', 'url' => '/api/v{apiVersion}/boards/{boardId}/views', 'verb' => 'GET'],
+ ['name' => 'board_view_api#create', 'url' => '/api/v{apiVersion}/boards/{boardId}/views', 'verb' => 'POST'],
+ ['name' => 'board_view_api#update', 'url' => '/api/v{apiVersion}/boards/{boardId}/views/{viewId}', 'verb' => 'PUT'],
+ ['name' => 'board_view_api#delete', 'url' => '/api/v{apiVersion}/boards/{boardId}/views/{viewId}', 'verb' => 'DELETE'],
+
['name' => 'card_ocs#create', 'url' => '/api/v{apiVersion}/cards', 'verb' => 'POST'],
['name' => 'card_ocs#update', 'url' => '/api/v{apiVersion}/cards/{cardId}', 'verb' => 'PUT'],
['name' => 'card_ocs#assignLabel', 'url' => '/api/v{apiVersion}/cards/{cardId}/label/{labelId}', 'verb' => 'POST'],
diff --git a/lib/Controller/BoardViewApiController.php b/lib/Controller/BoardViewApiController.php
new file mode 100644
index 000000000..024fd7ac4
--- /dev/null
+++ b/lib/Controller/BoardViewApiController.php
@@ -0,0 +1,53 @@
+boardViewService->findAll($boardId));
+ }
+
+ #[NoAdminRequired]
+ public function all(): DataResponse {
+ return new DataResponse($this->boardViewService->findAllForUser());
+ }
+
+ #[NoAdminRequired]
+ public function create(int $boardId, string $name, array $filters): DataResponse {
+ return new DataResponse($this->boardViewService->create($boardId, $name, $filters));
+ }
+
+ #[NoAdminRequired]
+ public function update(int $boardId, int $viewId, string $name, array $filters): DataResponse {
+ return new DataResponse($this->boardViewService->update($boardId, $viewId, $name, $filters));
+ }
+
+ #[NoAdminRequired]
+ public function delete(int $boardId, int $viewId): DataResponse {
+ $this->boardViewService->delete($boardId, $viewId);
+ return new DataResponse([]);
+ }
+}
diff --git a/lib/Db/BoardView.php b/lib/Db/BoardView.php
new file mode 100644
index 000000000..76e21b36c
--- /dev/null
+++ b/lib/Db/BoardView.php
@@ -0,0 +1,34 @@
+addType('id', 'integer');
+ $this->addType('boardId', 'integer');
+ $this->addType('createdAt', 'integer');
+ $this->addType('lastModifiedAt', 'integer');
+ }
+
+ public function jsonSerialize(): array {
+ $json = parent::jsonSerialize();
+ if (isset($json['filters']) && is_string($json['filters'])) {
+ $json['filters'] = json_decode($json['filters'], true) ?? [];
+ }
+ return $json;
+ }
+}
diff --git a/lib/Db/BoardViewMapper.php b/lib/Db/BoardViewMapper.php
new file mode 100644
index 000000000..fbe1648e4
--- /dev/null
+++ b/lib/Db/BoardViewMapper.php
@@ -0,0 +1,55 @@
+ */
+class BoardViewMapper extends QBMapper {
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, 'deck_board_views', BoardView::class);
+ }
+
+ /**
+ * @return BoardView[]
+ */
+ public function findAll(int $boardId, string $userId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('board_id', $qb->createNamedParameter($boardId, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('owner', $qb->createNamedParameter($userId)))
+ ->orderBy('name', 'ASC');
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * @return BoardView[]
+ */
+ public function findAllForUser(string $userId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('owner', $qb->createNamedParameter($userId)))
+ ->orderBy('name', 'ASC');
+ return $this->findEntities($qb);
+ }
+
+ public function find(int $id, string $userId): BoardView {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->getTableName())
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)))
+ ->andWhere($qb->expr()->eq('owner', $qb->createNamedParameter($userId)));
+ return $this->findEntity($qb);
+ }
+}
diff --git a/lib/Migration/Version11003Date20260821120000.php b/lib/Migration/Version11003Date20260821120000.php
new file mode 100644
index 000000000..f523cbfae
--- /dev/null
+++ b/lib/Migration/Version11003Date20260821120000.php
@@ -0,0 +1,58 @@
+hasTable('deck_board_views')) {
+ $table = $schema->createTable('deck_board_views');
+ $table->addColumn('id', 'integer', [
+ 'autoincrement' => true,
+ 'notnull' => true,
+ 'length' => 4,
+ ]);
+ $table->addColumn('board_id', 'integer', [
+ 'notnull' => true,
+ 'length' => 4,
+ 'default' => 0,
+ ]);
+ $table->addColumn('name', 'string', [
+ 'notnull' => true,
+ 'length' => 255,
+ ]);
+ $table->addColumn('filters', 'text', [
+ 'notnull' => true,
+ ]);
+ $table->addColumn('owner', 'string', [
+ 'notnull' => true,
+ 'length' => 64,
+ ]);
+ $table->addColumn('created_at', 'integer', [
+ 'notnull' => true,
+ 'length' => 4,
+ 'default' => 0,
+ ]);
+ $table->addColumn('last_modified_at', 'integer', [
+ 'notnull' => true,
+ 'length' => 4,
+ 'default' => 0,
+ ]);
+ $table->setPrimaryKey(['id']);
+ $table->addIndex(['board_id', 'owner'], 'deck_board_views_idx_board_owner');
+ }
+ return $schema;
+ }
+}
diff --git a/lib/Service/BoardViewService.php b/lib/Service/BoardViewService.php
new file mode 100644
index 000000000..4f503b5cf
--- /dev/null
+++ b/lib/Service/BoardViewService.php
@@ -0,0 +1,82 @@
+boardService->find($boardId);
+ return $this->boardViewMapper->findAll($boardId, $this->userId ?? '');
+ }
+
+ /**
+ * @return BoardView[]
+ */
+ public function findAllForUser(): array {
+ return $this->boardViewMapper->findAllForUser($this->userId ?? '');
+ }
+
+ public function create(int $boardId, string $name, array $filters): BoardView {
+ $this->boardService->find($boardId);
+ if ($name === '') {
+ throw new BadRequestException('The name must not be empty');
+ }
+
+ $view = new BoardView();
+ $view->setBoardId($boardId);
+ $view->setName($name);
+ $view->setFilters(json_encode($filters));
+ $view->setOwner($this->userId ?? '');
+ $view->setCreatedAt(time());
+ $view->setLastModifiedAt(time());
+ return $this->boardViewMapper->insert($view);
+ }
+
+ public function update(int $boardId, int $viewId, string $name, array $filters): BoardView {
+ $this->boardService->find($boardId);
+ $view = $this->boardViewMapper->find($viewId, $this->userId ?? '');
+ if ($view->getBoardId() !== $boardId) {
+ throw new BadRequestException('The view does not belong to the given board');
+ }
+
+ if ($name !== '') {
+ $view->setName($name);
+ }
+ $view->setFilters(json_encode($filters));
+ $view->setLastModifiedAt(time());
+ return $this->boardViewMapper->update($view);
+ }
+
+ /**
+ * @throws DoesNotExistException
+ */
+ public function delete(int $boardId, int $viewId): void {
+ $this->boardService->find($boardId);
+ $view = $this->boardViewMapper->find($viewId, $this->userId ?? '');
+ if ($view->getBoardId() !== $boardId) {
+ throw new BadRequestException('The view does not belong to the given board');
+ }
+ $this->boardViewMapper->delete($view);
+ }
+}
diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php
index 1dfa60d4e..c0dd72378 100644
--- a/lib/Service/ConfigService.php
+++ b/lib/Service/ConfigService.php
@@ -52,7 +52,8 @@ public function getAll(): array {
$data = [
'calendar' => $this->isCalendarEnabled(),
'cardDetailsInModal' => $this->isCardDetailsInModal(),
- 'cardIdBadge' => $this->isCardIdBadgeEnabled()
+ 'cardIdBadge' => $this->isCardIdBadgeEnabled(),
+ 'defaultBoardView' => $this->getDefaultBoardView(),
];
if ($this->groupManager->isAdmin($userId)) {
$data['groupLimit'] = $this->get('groupLimit');
@@ -62,7 +63,7 @@ public function getAll(): array {
}
/**
- * @return bool|array{id: string, displayname: string}[]
+ * @return bool|int|null|array{id: string, displayname: string}[]
* @throws NoPermissionException
*/
public function get(string $key) {
@@ -90,10 +91,21 @@ public function get(string $key) {
return false;
}
return (bool)$this->config->getUserValue($this->getUserId(), Application::APP_ID, 'cardIdBadge', false);
+ case 'defaultBoardView':
+ return $this->getDefaultBoardView();
}
return false;
}
+ public function getDefaultBoardView(): ?int {
+ $userId = $this->getUserId();
+ if ($userId === null) {
+ return null;
+ }
+ $value = $this->config->getUserValue($userId, Application::APP_ID, 'defaultBoardView', '');
+ return $value === '' ? null : (int)$value;
+ }
+
public function isCalendarEnabled(?int $boardId = null): bool {
$userId = $this->getUserId();
if ($userId === null) {
@@ -181,6 +193,10 @@ public function set($key, $value) {
$this->config->setUserValue($userId, Application::APP_ID, 'cardIdBadge', (string)$value);
$result = $value;
break;
+ case 'defaultBoardView':
+ $this->config->setUserValue($userId, Application::APP_ID, 'defaultBoardView', $value === null ? '' : (string)(int)$value);
+ $result = $value === null ? '' : $value;
+ break;
case 'board':
// extra check that user only send one of the allowed board settings and not something random
$parts = explode(':', $key, 3);
diff --git a/src/components/Controls.vue b/src/components/Controls.vue
index c449af0e2..96cf45339 100644
--- a/src/components/Controls.vue
+++ b/src/components/Controls.vue
@@ -101,6 +101,41 @@
+
+
{{ t('deck', 'Saved views') }}
+
+
+
+
+
+
+ {{ view.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('deck', 'Filter by tag') }}
{{ t('deck', 'No due date') }}
+
+
+
+
+
+
+ {{ t('deck', 'Save view') }}
+
+
+
{{ t('deck', 'Clear filter') }}
@@ -293,6 +343,11 @@ import ArchiveIcon from 'vue-material-design-icons/ArchiveOutline.vue'
import ImageIcon from 'vue-material-design-icons/ImageMultipleOutline.vue'
import FilterIcon from 'vue-material-design-icons/FilterOutline.vue'
import FilterOffIcon from 'vue-material-design-icons/FilterOffOutline.vue'
+import BookmarkOutline from 'vue-material-design-icons/BookmarkOutline.vue'
+import BookmarkOffOutline from 'vue-material-design-icons/BookmarkOffOutline.vue'
+import ContentSave from 'vue-material-design-icons/ContentSave.vue'
+import StarIcon from 'vue-material-design-icons/Star.vue'
+import TrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import TableColumnPlusAfter from 'vue-material-design-icons/TableColumnPlusAfter.vue'
import ArrowCollapseVerticalIcon from 'vue-material-design-icons/ArrowCollapseVertical.vue'
import ArrowExpandVerticalIcon from 'vue-material-design-icons/ArrowExpandVertical.vue'
@@ -321,6 +376,11 @@ export default {
ImageIcon,
FilterIcon,
FilterOffIcon,
+ BookmarkOutline,
+ BookmarkOffOutline,
+ ContentSave,
+ StarIcon,
+ TrashCanOutline,
ArrowCollapseVerticalIcon,
ArrowExpandVerticalIcon,
ViewColumnIcon,
@@ -362,6 +422,7 @@ export default {
filterVisible: false,
isAddStackVisible: false,
filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' },
+ newViewName: '',
showAddCardModal: false,
defaultPageTitle: false,
isNotifyPushEnabled: isNotifyPushEnabled(),
@@ -374,6 +435,8 @@ export default {
'canManage',
'viewMode',
'showArchived',
+ 'boardViews',
+ 'allBoardViews',
]),
...mapStateVuex({
isFullApp: state => state.isFullApp,
@@ -393,6 +456,19 @@ export default {
labelsSorted() {
return [...this.board.labels].sort((a, b) => (a.title < b.title) ? -1 : 1)
},
+ displayViews() {
+ const defaultId = this.$store.getters.config('defaultBoardView')
+ const defaultView = defaultId ? this.allBoardViews.find((view) => view.id === defaultId) : null
+ const isLocalDefault = defaultView && this.boardViews.some((view) => view.id === defaultView.id)
+ const localViews = this.boardViews.filter((view) => !defaultView || view.id !== defaultView.id)
+ if (!defaultView) {
+ return localViews.map((view) => ({ ...view, isDefault: false, isLocal: true }))
+ }
+ return [
+ { ...defaultView, isDefault: true, isLocal: isLocalDefault },
+ ...localViews.map((view) => ({ ...view, isDefault: false, isLocal: true })),
+ ]
+ },
presentUsers() {
if (!this.board) return []
// get user object including displayname from the list of all users with acces
@@ -403,6 +479,11 @@ export default {
board(current, previous) {
if (current?.id !== previous?.id) {
this.clearFilter()
+ this.newViewName = ''
+ if (current?.id) {
+ this.loadBoardViews(current.id)
+ this.loadAllBoardViews().then(() => this.applyDefaultView())
+ }
}
if (current) {
this.setPageTitle(current.title)
@@ -424,7 +505,16 @@ export default {
this.setPageTitle('')
},
methods: {
- ...mapActions(useBoardStore, { setViewMode: 'setViewMode', toggleShowArchived: 'toggleShowArchived', setFilterInStore: 'setFilterInStore' }),
+ ...mapActions(useBoardStore, {
+ setViewMode: 'setViewMode',
+ toggleShowArchived: 'toggleShowArchived',
+ setFilterInStore: 'setFilterInStore',
+ loadBoardViews: 'loadBoardViews',
+ loadAllBoardViews: 'loadAllBoardViews',
+ createBoardView: 'createBoardView',
+ deleteBoardView: 'deleteBoardView',
+ applyBoardView: 'applyBoardView',
+ }),
...mapActions(useStackStore, ['createStack']),
beforeSetFilter(e) {
if (this.filter.due === e.target.value) {
@@ -486,6 +576,56 @@ export default {
this.setFilterInStore({ ...filterReset })
this.filter = filterReset
},
+ applyView(view) {
+ this.applyBoardView(view)
+ this.filter = {
+ tags: [...(view.filters?.tags || [])],
+ users: [...(view.filters?.users || [])],
+ due: view.filters?.due || '',
+ unassigned: view.filters?.unassigned || false,
+ completed: view.filters?.completed || 'both',
+ }
+ },
+ applyDefaultView() {
+ if (!this.board) {
+ return
+ }
+ const viewId = this.$store.getters.config('defaultBoardView')
+ if (!viewId) {
+ return
+ }
+ const view = this.allBoardViews.find((v) => v.id === viewId)
+ if (!view) {
+ return
+ }
+ const filters = view.filters || {}
+ const boardLabelIds = this.board.labels?.map((label) => label.id) || []
+ const matchingTags = (filters.tags || []).filter((tag) => boardLabelIds.includes(tag))
+ this.filter = {
+ tags: matchingTags,
+ users: [...(filters.users || [])],
+ due: filters.due || '',
+ unassigned: filters.unassigned || false,
+ completed: filters.completed || 'both',
+ }
+ this.setFilterInStore({ ...this.filter })
+ },
+ removeDefaultView() {
+ this.$store.dispatch('setConfig', { defaultBoardView: null })
+ },
+ async removeView(view) {
+ await this.deleteBoardView(view.boardId, view.id)
+ if (this.$store.getters.config('defaultBoardView') === view.id) {
+ this.$store.dispatch('setConfig', { defaultBoardView: null })
+ }
+ },
+ async saveView() {
+ if (!this.isFilterActive || this.newViewName.trim() === '' || !this.board) {
+ return
+ }
+ await this.createBoardView(this.board.id, this.newViewName.trim())
+ this.newViewName = ''
+ },
clickShowAddCardModel() {
this.showAddCardModal = true
},
@@ -655,6 +795,30 @@ export default {
margin-bottom: 5px;
}
+ .filter--saved-views {
+ border-bottom: 1px solid var(--color-border);
+ margin-bottom: 8px;
+ padding-bottom: 4px;
+
+ .filter--saved-view {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ .filter--saved-view-name {
+ flex-grow: 1;
+ justify-content: flex-start;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+ }
+
+ .filter--save-view {
+ margin-top: 8px;
+ }
+
.filter-button {
padding: 0;
border-radius: 50%;
diff --git a/src/components/DeckAppSettings.vue b/src/components/DeckAppSettings.vue
index 3935dbff5..14e21aaef 100644
--- a/src/components/DeckAppSettings.vue
+++ b/src/components/DeckAppSettings.vue
@@ -13,6 +13,18 @@
+
+
+
+ {{ t('deck', 'The selected view is applied automatically when you open any board.') }}
+
+
@@ -79,6 +91,8 @@ import { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css' // Required for dialog styles
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
+import { mapActions, mapState } from 'pinia'
+import { useBoardStore } from '../stores/board.js'
export default {
name: 'DeckAppSettings',
@@ -108,9 +122,21 @@ export default {
},
computed: {
+ ...mapState(useBoardStore, ['allBoardViews']),
isAdmin() {
return !!getCurrentUser()?.isAdmin
},
+ boardViewOptions() {
+ const boards = useBoardStore().boards
+ return this.allBoardViews.map((view) => {
+ const board = boards.find((b) => b.id === view.boardId)
+ const boardTitle = board?.title || `#${view.boardId}`
+ return {
+ id: view.id,
+ label: `${view.name} (${boardTitle})`,
+ }
+ })
+ },
cardDetailsInModal: {
get() {
return this.$store.getters.config('cardDetailsInModal')
@@ -127,6 +153,18 @@ export default {
this.$store.dispatch('setConfig', { cardIdBadge: newValue })
},
},
+ defaultBoardView: {
+ get() {
+ const id = this.$store.getters.config('defaultBoardView')
+ if (!id) {
+ return null
+ }
+ return this.boardViewOptions.find((view) => view.id === id) || null
+ },
+ set(view) {
+ this.$store.dispatch('setConfig', { defaultBoardView: view ? view.id : null })
+ },
+ },
federationEnabled: {
get() {
const value = this.$store.getters.config('federationEnabled')
@@ -149,6 +187,7 @@ export default {
},
beforeMount() {
+ this.loadAllBoardViews()
if (this.isAdmin) {
this.groupLimit = this.$store.getters.config('groupLimit')
axios.get(generateOcsUrl('cloud/groups')).then((response) => {
@@ -174,6 +213,7 @@ export default {
},
methods: {
+ ...mapActions(useBoardStore, ['loadAllBoardViews']),
onClose() {
this.$emit('close')
},
@@ -200,5 +240,11 @@ export default {
settings-section_admin-settings p {
margin-bottom: 20px;
}
+
+ .settings-hint {
+ margin: 0;
+ padding-inline-start: calc(var(--default-clickable-area) * 0.5);
+ color: var(--color-text-maxcontrast);
+ }
}
diff --git a/src/services/BoardApi.js b/src/services/BoardApi.js
index a6551b56d..7f663ae79 100644
--- a/src/services/BoardApi.js
+++ b/src/services/BoardApi.js
@@ -368,4 +368,81 @@ export class BoardApi {
})
}
+ // Board view API calls
+
+ loadBoardViews(boardId) {
+ return axios.get(this.ocsUrl(`/boards/${boardId}/views`))
+ .then(
+ (response) => {
+ return Promise.resolve(response.data.ocs.data)
+ },
+ (err) => {
+ return Promise.reject(err)
+ },
+ )
+ .catch((err) => {
+ return Promise.reject(err)
+ })
+ }
+
+ loadAllBoardViews() {
+ return axios.get(this.ocsUrl('/views'))
+ .then(
+ (response) => {
+ return Promise.resolve(response.data.ocs.data)
+ },
+ (err) => {
+ return Promise.reject(err)
+ },
+ )
+ .catch((err) => {
+ return Promise.reject(err)
+ })
+ }
+
+ createBoardView(boardId, name, filters) {
+ return axios.post(this.ocsUrl(`/boards/${boardId}/views`), { name, filters })
+ .then(
+ (response) => {
+ return Promise.resolve(response.data.ocs.data)
+ },
+ (err) => {
+ return Promise.reject(err)
+ },
+ )
+ .catch((err) => {
+ return Promise.reject(err)
+ })
+ }
+
+ updateBoardView(boardId, view) {
+ return axios.put(this.ocsUrl(`/boards/${boardId}/views/${view.id}`), view)
+ .then(
+ (response) => {
+ return Promise.resolve(response.data.ocs.data)
+ },
+ (err) => {
+ return Promise.reject(err)
+ },
+ )
+ .catch((err) => {
+ return Promise.reject(err)
+ })
+ }
+
+ deleteBoardView(boardId, viewId) {
+ return axios.delete(this.ocsUrl(`/boards/${boardId}/views/${viewId}`))
+ .then(
+ (response) => {
+ return Promise.resolve(response.data.ocs.data)
+ },
+ (err) => {
+ return Promise.reject(err)
+ },
+ )
+ .catch((err) => {
+ return Promise.reject(err)
+ })
+ }
+
}
diff --git a/src/stores/board.js b/src/stores/board.js
index ded7af243..b0576011d 100644
--- a/src/stores/board.js
+++ b/src/stores/board.js
@@ -28,6 +28,8 @@ export const useBoardStore = defineStore('board', {
assignableUsers: [],
filter: { tags: [], users: [], due: '', unassigned: false, completed: 'both' },
boardFilter: BOARD_FILTERS.ALL,
+ boardViews: [],
+ allBoardViews: [],
boards: loadState('deck', 'initialBoards', {}),
}),
getters: {
@@ -143,10 +145,46 @@ export const useBoardStore = defineStore('board', {
},
async loadBoardById(boardId) {
this.filter = { tags: [], users: [], due: '', unassigned: false, completed: 'both' }
+ this.boardViews = []
this.setCurrentBoard(null)
const board = await apiClient.loadById(boardId)
this.setCurrentBoard(board)
this.setAssignableUsers(board.users)
+ await this.loadBoardViews(boardId)
+ },
+ async loadBoardViews(boardId) {
+ this.boardViews = await apiClient.loadBoardViews(boardId)
+ return this.boardViews
+ },
+ async loadAllBoardViews() {
+ this.allBoardViews = await apiClient.loadAllBoardViews()
+ return this.allBoardViews
+ },
+ async createBoardView(boardId, name) {
+ const view = await apiClient.createBoardView(boardId, name, this.filter)
+ this.boardViews.push(view)
+ this.allBoardViews.push(view)
+ return view
+ },
+ async updateBoardView(view) {
+ const updated = await apiClient.updateBoardView(view.boardId, view)
+ const index = this.boardViews.findIndex((v) => v.id === updated.id)
+ if (index > -1) {
+ Vue.set(this.boardViews, index, updated)
+ }
+ return updated
+ },
+ async deleteBoardView(boardId, viewId) {
+ await apiClient.deleteBoardView(boardId, viewId)
+ this.boardViews = this.boardViews.filter((v) => v.id !== viewId)
+ this.allBoardViews = this.allBoardViews.filter((v) => v.id !== viewId)
+ },
+ applyBoardView(view) {
+ this.setFilterInStore(this.normalizeFilter(view.filters))
+ },
+ normalizeFilter(filter) {
+ const defaults = { tags: [], users: [], due: '', unassigned: false, completed: 'both' }
+ return { ...defaults, ...(filter || {}) }
},
async refreshBoard(boardId) {
const board = await apiClient.loadById(boardId)
diff --git a/tests/unit/Service/BoardViewServiceTest.php b/tests/unit/Service/BoardViewServiceTest.php
new file mode 100644
index 000000000..58be3a020
--- /dev/null
+++ b/tests/unit/Service/BoardViewServiceTest.php
@@ -0,0 +1,106 @@
+boardViewMapper = $this->createMock(BoardViewMapper::class);
+ $this->boardService = $this->createMock(BoardService::class);
+ $this->boardViewService = new BoardViewService(
+ $this->boardViewMapper,
+ $this->boardService,
+ 'user123',
+ );
+ }
+
+ public function testFindAll() {
+ $view = new BoardView();
+ $this->boardService->expects($this->once())->method('find')->with(123);
+ $this->boardViewMapper->expects($this->once())->method('findAll')->with(123, 'user123')->willReturn([$view]);
+ $this->assertEquals([$view], $this->boardViewService->findAll(123));
+ }
+
+ public function testCreate() {
+ $filters = ['tags' => [1], 'users' => [], 'due' => 'overdue', 'unassigned' => false, 'completed' => 'open'];
+
+ $this->boardService->expects($this->once())->method('find')->with(123);
+ $this->boardViewMapper->expects($this->once())->method('insert')->willReturnCallback(function (BoardView $view) {
+ return $view;
+ });
+
+ $view = $this->boardViewService->create(123, 'My view', $filters);
+ $this->assertEquals(123, $view->getBoardId());
+ $this->assertEquals('My view', $view->getName());
+ $this->assertEquals('user123', $view->getOwner());
+ $this->assertEquals(json_encode($filters), $view->getFilters());
+ $this->assertNotNull($view->getCreatedAt());
+ $this->assertNotNull($view->getLastModifiedAt());
+ }
+
+ public function testCreateEmptyName() {
+ $this->expectException(BadRequestException::class);
+ $this->boardViewService->create(123, '', []);
+ }
+
+ public function testUpdate() {
+ $view = new BoardView();
+ $view->setId(1);
+ $view->setBoardId(123);
+ $view->setName('Old name');
+ $view->setFilters(json_encode(['tags' => []]));
+
+ $this->boardService->expects($this->once())->method('find')->with(123);
+ $this->boardViewMapper->expects($this->once())->method('find')->with(1, 'user123')->willReturn($view);
+ $this->boardViewMapper->expects($this->once())->method('update')->willReturnCallback(function (BoardView $view) {
+ return $view;
+ });
+
+ $updated = $this->boardViewService->update(123, 1, 'New name', ['tags' => [2]]);
+ $this->assertEquals('New name', $updated->getName());
+ $this->assertEquals(json_encode(['tags' => [2]]), $updated->getFilters());
+ }
+
+ public function testUpdateViewOfOtherBoard() {
+ $view = new BoardView();
+ $view->setId(1);
+ $view->setBoardId(456);
+
+ $this->boardService->expects($this->once())->method('find')->with(123);
+ $this->boardViewMapper->expects($this->once())->method('find')->with(1, 'user123')->willReturn($view);
+
+ $this->expectException(BadRequestException::class);
+ $this->boardViewService->update(123, 1, 'New name', []);
+ }
+
+ public function testDelete() {
+ $view = new BoardView();
+ $view->setId(1);
+ $view->setBoardId(123);
+
+ $this->boardService->expects($this->once())->method('find')->with(123);
+ $this->boardViewMapper->expects($this->once())->method('find')->with(1, 'user123')->willReturn($view);
+ $this->boardViewMapper->expects($this->once())->method('delete')->with($view);
+
+ $this->boardViewService->delete(123, 1);
+ }
+}