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); + } +}