diff --git a/appinfo/routes.php b/appinfo/routes.php index 8a2b9be5fe..0df5b22449 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 0000000000..13cc7e7000 --- /dev/null +++ b/lib/Controller/BoardViewApiController.php @@ -0,0 +1,52 @@ +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 0000000000..76e21b36c6 --- /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 0000000000..fbe1648e4c --- /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 0000000000..f41e08a162 --- /dev/null +++ b/lib/Migration/Version11003Date20260821120000.php @@ -0,0 +1,59 @@ +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'); + $table->addIndex(['owner'], 'deck_board_views_idx_owner'); + } + return $schema; + } +} diff --git a/lib/Service/BoardViewService.php b/lib/Service/BoardViewService.php new file mode 100644 index 0000000000..3166dc0ff9 --- /dev/null +++ b/lib/Service/BoardViewService.php @@ -0,0 +1,120 @@ +boardService->find($boardId, false); + 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, false); + $name = $this->validateName($name, true); + if (count($this->boardViewMapper->findAll($boardId, $this->userId ?? '')) >= self::MAX_VIEWS_PER_BOARD) { + throw new BadRequestException('You have reached the maximum number of saved views for this board'); + } + + $view = new BoardView(); + $view->setBoardId($boardId); + $view->setName($name); + $view->setFilters($this->encodeFilters($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, false); + $view = $this->boardViewMapper->find($viewId, $this->userId ?? ''); + if ($view->getBoardId() !== $boardId) { + throw new BadRequestException('The view does not belong to the given board'); + } + + $name = $this->validateName($name, false); + if ($name !== '') { + $view->setName($name); + } + $view->setFilters($this->encodeFilters($filters)); + $view->setLastModifiedAt(time()); + return $this->boardViewMapper->update($view); + } + + /** + * @throws DoesNotExistException + */ + public function delete(int $boardId, int $viewId): void { + $this->boardService->find($boardId, false); + $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); + + if ($this->configService->getDefaultBoardView() === $viewId) { + $this->configService->set('defaultBoardView', null); + } + } + + private function validateName(string $name, bool $required): string { + $name = trim($name); + if ($name === '') { + if ($required) { + throw new BadRequestException('The name must not be empty'); + } + return ''; + } + if (mb_strlen($name) > self::MAX_NAME_LENGTH) { + throw new BadRequestException('The name must not be longer than ' . self::MAX_NAME_LENGTH . ' characters'); + } + return $name; + } + + private function encodeFilters(array $filters): string { + try { + $encoded = json_encode($filters, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new BadRequestException('The filters are not valid JSON'); + } + if (strlen($encoded) > self::MAX_FILTERS_SIZE) { + throw new BadRequestException('The filters are too large'); + } + return $encoded; + } +} diff --git a/lib/Service/ConfigService.php b/lib/Service/ConfigService.php index 5e579c07f8..ec0527c801 100644 --- a/lib/Service/ConfigService.php +++ b/lib/Service/ConfigService.php @@ -57,7 +57,8 @@ public function getAll(): array { 'calendar' => $this->isCalendarEnabled(), 'cardDetailsInModal' => $this->isCardDetailsInModal(), 'cardIdBadge' => $this->isCardIdBadgeEnabled(), - 'stackAddCardAtTop' => $this->isStackAddCardAtTopEnabled() + 'stackAddCardAtTop' => $this->isStackAddCardAtTopEnabled(), + 'defaultBoardView' => $this->getDefaultBoardView(), ]; if ($this->groupManager->isAdmin($userId)) { $data['groupLimit'] = $this->get('groupLimit'); @@ -67,7 +68,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) { @@ -95,10 +96,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) { @@ -211,6 +223,10 @@ public function set($key, $value) { $this->config->setUserValue($userId, Application::APP_ID, 'stackAddCardAtTop', (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 2c09e1fbe7..1103023c57 100644 --- a/src/components/Controls.vue +++ b/src/components/Controls.vue @@ -107,6 +107,44 @@