From c81bcea1bd3e9ef14090244c5a6731f79b2da41c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 3 Nov 2026 22:07:37 +0100 Subject: [PATCH 1/2] feat(contexts): give applications a menu with its own API An application gets a menu of items. An item opens at most one thing: a page of the application, a table or view of the application by its node relation, or an http or https link. An item that opens nothing is a group; groups are top-level and only groups hold items, so a menu is one level deep. An item without a title shows the title of what it opens. An item has no technical name of its own. A menu holds at most 200 items. The menu has its own endpoints on api/2: GET /menu?contextId= returns the menu as a flat list in display order, POST /menu adds an item to the end, PATCH and DELETE /menu/{itemId} change or delete one item, and PUT /menu/order saves the order and grouping of the whole menu in one transaction. Reading needs read access to the application and changes need manage access, checked once through the item's application. Outsiders get a 404, readers who try a change a 403 and a target outside the application a 400. Create is limited to 20 a minute per user, a change or an order to 60. Of the three target columns (page, node relation, link) at most one is set, which the service enforces. Creating or changing an item checks its target in the transaction that saves it. Empty groups stay in the menu; readers without manage access don't see them. A new application gets one item, without a title, for its start page. An item goes when its target goes, in the same transaction: with its page, when its table or view leaves the application or is deleted, and with the application. The items of a deleted group move to the top level. An item whose target is gone anyway is left out when the menu is read. Applications that exist today have no items and open their start page. Co-authored-by: remko48 Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- appinfo/info.xml | 2 +- lib/Controller/MenuController.php | 200 ++++++++ lib/Db/MenuItem.php | 88 ++++ lib/Db/MenuItemMapper.php | 106 +++++ .../Version2500Date20261103000000.php | 52 +++ lib/ResponseDefinitions.php | 11 + lib/Service/ContextService.php | 15 + lib/Service/MenuService.php | 431 ++++++++++++++++++ lib/Service/PageService.php | 6 +- package-lock.json | 4 +- package.json | 2 +- tests/unit/Controller/MenuControllerTest.php | 86 ++++ .../Service/ContextPagesRoundTripTest.php | 2 + tests/unit/Service/ContextServiceTest.php | 2 + tests/unit/Service/MenuServiceTest.php | 322 +++++++++++++ tests/unit/Service/PageServiceTest.php | 2 + 16 files changed, 1326 insertions(+), 5 deletions(-) create mode 100644 lib/Controller/MenuController.php create mode 100644 lib/Db/MenuItem.php create mode 100644 lib/Db/MenuItemMapper.php create mode 100644 lib/Migration/Version2500Date20261103000000.php create mode 100644 lib/Service/MenuService.php create mode 100644 tests/unit/Controller/MenuControllerTest.php create mode 100644 tests/unit/Service/MenuServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 4edcef88db..506468495d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.5.0-dev.0 + 2.5.0-dev.1 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Controller/MenuController.php b/lib/Controller/MenuController.php new file mode 100644 index 0000000000..295307345b --- /dev/null +++ b/lib/Controller/MenuController.php @@ -0,0 +1,200 @@ +, array{}>|DataResponse + * + * 200: Menu returned + * 404: Not found, also when the user has no access to the application + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')] + #[ApiRoute(verb: 'GET', url: '/api/2/menu')] + public function index(int $contextId): DataResponse { + try { + return new DataResponse($this->menuService->findAll($contextId, $this->userId)); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Add an item to the end of the menu of an application + * + * The `target` is `{pageId}` of a page or `{nodeRelId}` of a table or view of the + * application, or `{url}` of an http or https link. Without a target the item is a + * group, which needs a title. Without a title an item shows the title of what it opens. + * + * @param int $contextId ID of the application + * @param string|null $title Title of the item + * @param string|null $icon Name of the icon shown with the title + * @param string|null $description Description of the item + * @param array|null $target What the item opens + * @return DataResponse|DataResponse + * + * 200: Item created + * 400: Invalid field, a target outside the application, or a full menu + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] + #[ApiRoute(verb: 'POST', url: '/api/2/menu')] + public function create(int $contextId, ?string $title = null, ?string $icon = null, ?string $description = null, ?array $target = null): DataResponse { + try { + return new DataResponse($this->menuService->create($contextId, $title, $icon, $description, $target, $this->userId)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Save the order and grouping of the menu of an application + * + * `items` lists every item of the menu exactly once, in display order, as + * `{id, parentId}`; `parentId` is a group at the top level listed earlier, or null. + * Either all of it is saved or nothing. + * + * @param int $contextId ID of the application + * @param list $items Every item of the menu + * @return DataResponse, array{}>|DataResponse + * + * 200: Menu saved + * 400: An item missing or listed twice, a parent that is not a group, or the menu changed in between + * 403: No permissions + * 404: Not found, also for an item of another application + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 60, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] + #[ApiRoute(verb: 'PUT', url: '/api/2/menu/order')] + public function order(int $contextId, array $items): DataResponse { + try { + return new DataResponse($this->menuService->saveOrder($contextId, $items, $this->userId)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Change an item of a menu + * + * Fields left out keep their value; an empty title, icon or description removes it. + * A target replaces the previous one. Position and group change only through the order. + * + * @param int $itemId ID of the item + * @param string|null $title New title, empty to show the title of what the item opens + * @param string|null $icon New icon name, empty to remove it + * @param string|null $description New description, empty to remove it + * @param array|null $target New target + * @return DataResponse|DataResponse + * + * 200: Item updated + * 400: Invalid field or a target outside the application + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 60, period: 60)] + #[ApiRoute(verb: 'PATCH', url: '/api/2/menu/{itemId}', requirements: ['itemId' => '\d+'])] + public function update(int $itemId, ?string $title = null, ?string $icon = null, ?string $description = null, ?array $target = null): DataResponse { + try { + return new DataResponse($this->menuService->update($itemId, $title, $icon, $description, $target, $this->userId)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Delete an item of a menu + * + * The items of a deleted group move to the top level. + * + * @param int $itemId ID of the item + * @return DataResponse|DataResponse + * + * 200: Item deleted + * 403: No permissions + * 404: Not found + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/2/menu/{itemId}', requirements: ['itemId' => '\d+'])] + public function destroy(int $itemId): DataResponse { + try { + return new DataResponse($this->menuService->delete($itemId, $this->userId)); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } +} diff --git a/lib/Db/MenuItem.php b/lib/Db/MenuItem.php new file mode 100644 index 0000000000..a383d9974a --- /dev/null +++ b/lib/Db/MenuItem.php @@ -0,0 +1,88 @@ +addType('id', 'integer'); + $this->addType('contextId', 'integer'); + $this->addType('parentId', 'integer'); + $this->addType('order', 'integer'); + $this->addType('pageId', 'integer'); + $this->addType('nodeRelId', 'integer'); + } + + public function isGroup(): bool { + return $this->pageId === null && $this->nodeRelId === null && $this->url === null; + } + + /** + * @return array{pageId: int}|array{nodeRelId: int}|array{url: string}|null + */ + public function getTarget(): ?array { + return match (true) { + $this->pageId !== null => ['pageId' => $this->pageId], + $this->nodeRelId !== null => ['nodeRelId' => $this->nodeRelId], + $this->url !== null => ['url' => $this->url], + default => null, + }; + } + + /** + * @param array{pageId: int}|array{nodeRelId: int}|array{url: string} $target + */ + public function setTarget(array $target): void { + $this->setPageId($target['pageId'] ?? null); + $this->setNodeRelId($target['nodeRelId'] ?? null); + $this->setUrl($target['url'] ?? null); + } +} diff --git a/lib/Db/MenuItemMapper.php b/lib/Db/MenuItemMapper.php new file mode 100644 index 0000000000..7db18051c6 --- /dev/null +++ b/lib/Db/MenuItemMapper.php @@ -0,0 +1,106 @@ + */ +class MenuItemMapper extends QBMapper { + protected string $table = 'tables_contexts_menu_item'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, MenuItem::class); + } + + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function findById(int $itemId): MenuItem { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($itemId, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + /** + * @return list in menu order + * @throws Exception + */ + public function findByContextId(int $contextId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('context_id', $qb->createNamedParameter($contextId, IQueryBuilder::PARAM_INT))) + ->orderBy('order', 'ASC') + ->addOrderBy('id', 'ASC'); + return $this->findEntities($qb); + } + + /** + * Moves the items of a group to the top level. + * + * @throws Exception + */ + public function clearParent(int $parentId): void { + $qb = $this->db->getQueryBuilder(); + $qb->update($this->table) + ->set('parent_id', $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL)) + ->where($qb->expr()->eq('parent_id', $qb->createNamedParameter($parentId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } + + /** + * @throws Exception + */ + public function deleteByContextId(int $contextId): void { + $this->deleteWhereIn('context_id', [$contextId]); + } + + /** + * @param int[] $pageIds + * @throws Exception + */ + public function deleteByPageIds(array $pageIds): void { + $this->deleteWhereIn('page_id', $pageIds); + } + + /** + * @param int[] $nodeRelIds + * @throws Exception + */ + public function deleteByNodeRelIds(array $nodeRelIds): void { + $this->deleteWhereIn('node_rel_id', $nodeRelIds); + } + + /** + * @param int[] $values + * @throws Exception + */ + private function deleteWhereIn(string $column, array $values): void { + if ($values === []) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->in($column, $qb->createParameter('values'))); + foreach (array_chunk($values, 1_000) as $chunk) { + $qb->setParameter('values', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + $qb->executeStatement(); + } + } +} diff --git a/lib/Migration/Version2500Date20261103000000.php b/lib/Migration/Version2500Date20261103000000.php new file mode 100644 index 0000000000..0a67d67bed --- /dev/null +++ b/lib/Migration/Version2500Date20261103000000.php @@ -0,0 +1,52 @@ +hasTable(self::TABLE)) { + return null; + } + + $table = $schema->createTable(self::TABLE); + $table->addColumn('id', Types::INTEGER, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('context_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('parent_id', Types::INTEGER, ['notnull' => false]); + $table->addColumn('order', Types::INTEGER, ['notnull' => true, 'default' => 0]); + $table->addColumn('title', Types::STRING, ['notnull' => false, 'length' => 200]); + $table->addColumn('icon', Types::STRING, ['notnull' => false, 'length' => 64]); + $table->addColumn('description', Types::TEXT, ['notnull' => false]); + $table->addColumn('page_id', Types::INTEGER, ['notnull' => false]); + $table->addColumn('node_rel_id', Types::INTEGER, ['notnull' => false]); + $table->addColumn('url', Types::STRING, ['notnull' => false, 'length' => 2000]); + $table->setPrimaryKey(['id']); + $table->addIndex(['context_id'], 'tables_ctx_menu_ctx_idx'); + $table->addIndex(['page_id'], 'tables_ctx_menu_page_idx'); + $table->addIndex(['node_rel_id'], 'tables_ctx_menu_rel_idx'); + + return $schema; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index d750e9a969..3cd54649a6 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -287,6 +287,17 @@ * widgets: list, * } * + * @psalm-type TablesMenuItem = array{ + * id: int, + * contextId: int, + * parentId: ?int, + * title: ?string, + * displayTitle: string, + * icon: ?string, + * description: ?string, + * target: array{pageId: int}|array{nodeRelId: int}|array{url: string}|null, + * } + * * @psalm-type TablesContextNavigation = array{ * id: int, * shareId: int, diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index ed01048c24..f2e6c4f155 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -15,6 +15,8 @@ use OCA\Tables\Db\ContextMapper; use OCA\Tables\Db\ContextNodeRelation; use OCA\Tables\Db\ContextNodeRelationMapper; +use OCA\Tables\Db\MenuItem; +use OCA\Tables\Db\MenuItemMapper; use OCA\Tables\Db\Page; use OCA\Tables\Db\PageContent; use OCA\Tables\Db\PageContentMapper; @@ -66,6 +68,7 @@ public function __construct( private ViewMapper $viewMapper, private WidgetService $widgetService, private GridWidgetTypes $gridWidgetTypes, + private MenuItemMapper $menuItemMapper, ) { } @@ -134,6 +137,9 @@ public function findById(int $id, ?string $userId): Context { } /** + * Creates an application with its start page and a menu of one item, without a + * title, opening the start page. + * * @psalm-param list $nodes * @throws Exception|PermissionError|InvalidArgumentException */ @@ -153,6 +159,12 @@ public function create(string $name, string $iconName, string $description, arra $this->insertNodesFromArray($context, $nodes); } $this->insertPage($context); + + $startPageItem = new MenuItem(); + $startPageItem->setContextId($context->getId()); + $startPageItem->setOrder(0); + $startPageItem->setPageId((int)array_key_first($context->getPages())); + $this->menuItemMapper->insert($startPageItem); }, $this->dbc); $this->auditLogService->log(Operation::ContextCreated, 'Tables application with ID %s was created for user %s', [ 'contextId' => $context->getId(), @@ -343,6 +355,7 @@ public function delete(int $contextId, string $userId): Context { $this->atomic(function () use ($context): void { $this->shareService->deleteAllForContext($context); + $this->menuItemMapper->deleteByContextId($context->getId()); $this->contextNodeRelMapper->deleteAllByContextId($context->getId()); $pageIds = $this->pageMapper->getPageIdsForContext($context->getId()); $this->widgetService->deleteByPageIds($pageIds); @@ -364,6 +377,7 @@ public function deleteNodeRel(int $nodeId, int $nodeType): void { $nodeRelIds = $this->contextNodeRelMapper->getRelIdsForNode($nodeId, $nodeType); $this->atomic(function () use ($nodeRelIds): void { $this->widgetService->deleteSourcesByNodeRelIds($nodeRelIds); + $this->menuItemMapper->deleteByNodeRelIds($nodeRelIds); $this->pageContentMapper->deleteByNodeRelIds($nodeRelIds); $this->contextNodeRelMapper->deleteByNodeRelIds($nodeRelIds); }, $this->dbc); @@ -461,6 +475,7 @@ public function addNodeToContextAndStartpage(int $contextId, int $nodeId, int $n public function removeNodeFromContextAndPages(int $nodeRelationId): array { return $this->atomic(function () use ($nodeRelationId): array { $this->widgetService->deleteSourcesByNodeRelIds([$nodeRelationId]); + $this->menuItemMapper->deleteByNodeRelIds([$nodeRelationId]); $nodeRelation = $this->removeNodeFromContextById($nodeRelationId); $contents = $this->removeNodeRelFromAllPages($nodeRelation); return [$nodeRelation, $contents]; diff --git a/lib/Service/MenuService.php b/lib/Service/MenuService.php new file mode 100644 index 0000000000..49a8fed24f --- /dev/null +++ b/lib/Service/MenuService.php @@ -0,0 +1,431 @@ + in menu order + * @throws NotFoundError + * @throws Exception + */ + public function findAll(int $contextId, string $userId): array { + $context = $this->contextMapper->findById($contextId, $userId); + $menu = $this->serialize($context, $this->menuItemMapper->findByContextId($contextId)); + if ($this->permissionsService->canManageContext($context, $userId)) { + return $menu; + } + $parentIds = array_flip(array_filter(array_column($menu, 'parentId'))); + return array_values(array_filter($menu, static fn (array $item): bool => $item['target'] !== null || isset($parentIds[$item['id']]))); + } + + /** + * Adds an item to the end of the menu. Without a target the item is a group, which + * needs a title. The target is checked in the transaction that saves the item. + * + * @param array|null $target + * @return TablesMenuItem + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function create(int $contextId, ?string $title, ?string $icon, ?string $description, ?array $target, string $userId): array { + return $this->atomic(function () use ($contextId, $title, $icon, $description, $target, $userId): array { + $context = $this->getManageableContext($contextId, $userId); + $items = $this->menuItemMapper->findByContextId($contextId); + if (count($items) >= MenuItem::MAX_ITEMS_PER_APPLICATION) { + throw new BadRequestError('A menu holds at most ' . MenuItem::MAX_ITEMS_PER_APPLICATION . ' items.'); + } + + $item = new MenuItem(); + $item->setContextId($contextId); + $item->setOrder($items === [] ? 0 : max(array_map(static fn (MenuItem $item): int => $item->getOrder(), $items)) + 1); + $item->setTitle($this->validateTitle($title)); + $item->setIcon($this->validateIcon($icon)); + $item->setDescription($this->validateDescription($description)); + if ($target !== null) { + $item->setTarget($this->validateTarget($target, $context)); + } + $this->assertValid($item); + + return $this->serializeOne($context, $this->menuItemMapper->insert($item)); + }, $this->dbc); + } + + /** + * Changes the fields of an item. A null argument keeps the current value; an empty + * title, icon or description removes it, and an item without a title shows the title + * of what it opens. A target replaces the previous one and is checked in the + * transaction that saves it; a group that holds items cannot get one. + * + * @param array|null $target + * @return TablesMenuItem + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function update(int $itemId, ?string $title, ?string $icon, ?string $description, ?array $target, string $userId): array { + return $this->atomic(function () use ($itemId, $title, $icon, $description, $target, $userId): array { + [$item, $context] = $this->getManageableItem($itemId, $userId); + + if ($title !== null) { + $item->setTitle($this->validateTitle($title)); + } + if ($icon !== null) { + $item->setIcon($this->validateIcon($icon)); + } + if ($description !== null) { + $item->setDescription($this->validateDescription($description)); + } + if ($target !== null) { + foreach ($this->menuItemMapper->findByContextId($context->getId()) as $other) { + if ($other->getParentId() === $itemId) { + throw new BadRequestError('A group that holds items cannot open something. Move its items out first.'); + } + } + $item->setTarget($this->validateTarget($target, $context)); + } + $this->assertValid($item); + + if ($item->getUpdatedFields() !== []) { + $this->menuItemMapper->update($item); + } + return $this->serializeOne($context, $item); + }, $this->dbc); + } + + /** + * Deletes an item. The items of a deleted group move to the top level. + * + * @return TablesMenuItem the deleted item + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function delete(int $itemId, string $userId): array { + [$item, $context] = $this->getManageableItem($itemId, $userId); + $deleted = $this->serializeOne($context, $item); + + $this->atomic(function () use ($item): void { + $this->menuItemMapper->clearParent($item->getId()); + $this->menuItemMapper->delete($item); + }, $this->dbc); + + return $deleted; + } + + /** + * Saves the order and grouping of the whole menu in one transaction. The list holds + * every item exactly once, in display order, each with the id of its group or null. + * Only top-level groups hold items, and a group comes before its items. A menu that + * changed since it was read is refused. + * + * @param list $order + * @return list the whole menu + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function saveOrder(int $contextId, array $order, string $userId): array { + $context = $this->getManageableContext($contextId, $userId); + + $this->atomic(function () use ($context, $order): void { + $items = []; + foreach ($this->visibleItems($context, $this->menuItemMapper->findByContextId($context->getId())) as $item) { + $items[$item->getId()] = $item; + } + + $parents = []; + $positions = []; + foreach (array_values($order) as $position => $entry) { + $id = is_array($entry) ? ($entry['id'] ?? null) : null; + $parentId = is_array($entry) ? ($entry['parentId'] ?? null) : null; + if (!is_int($id) || ($parentId !== null && !is_int($parentId))) { + throw new BadRequestError('Entry ' . $position . ' needs the id of an item and the id of its group or null.'); + } + if (!isset($items[$id])) { + throw new NotFoundError('Menu item ' . $id . ' is not an item of application ' . $context->getId()); + } + if (array_key_exists($id, $parents)) { + throw new BadRequestError('Menu item ' . $id . ' is listed twice.'); + } + $parents[$id] = $parentId; + $positions[$id] = $position; + } + if (count($parents) !== count($items)) { + throw new BadRequestError(self::CHANGED_IN_BETWEEN); + } + + foreach ($parents as $id => $parentId) { + if ($parentId !== null && ($items[$id]->isGroup() || !isset($positions[$parentId]) || $positions[$parentId] > $positions[$id] || !$items[$parentId]->isGroup() || $parents[$parentId] !== null)) { + throw new BadRequestError('Menu item ' . $id . ' can only sit in a group at the top level listed before it, and groups cannot sit in a group.'); + } + } + + $position = 0; + foreach ($parents as $id => $parentId) { + $items[$id]->setOrder($position++); + $items[$id]->setParentId($parentId); + if ($items[$id]->getUpdatedFields() !== []) { + $this->menuItemMapper->update($items[$id]); + } + } + }, $this->dbc); + + return $this->findAll($contextId, $userId); + } + + /** + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + private function getManageableContext(int $contextId, string $userId): Context { + $context = $this->contextMapper->findById($contextId, $userId); + if (!$this->permissionsService->canManageContext($context, $userId)) { + throw new PermissionError(sprintf('User %s cannot manage application %d', $userId, $contextId)); + } + return $context; + } + + /** + * An item with its application, checked once through that application. An item + * whose target is gone is not found, as it is left out of the menu. + * + * @return array{MenuItem, Context} + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + private function getManageableItem(int $itemId, string $userId): array { + try { + $item = $this->menuItemMapper->findById($itemId); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + throw new NotFoundError('Menu item ' . $itemId . ' does not exist', 0, $e); + } + $context = $this->getManageableContext($item->getContextId(), $userId); + if ($this->visibleItems($context, [$item]) === []) { + throw new NotFoundError('Menu item ' . $itemId . ' does not exist'); + } + return [$item, $context]; + } + + /** + * @param list $items + * @return list the items whose target is a page, table or view of the application, a link, or none + */ + private function visibleItems(Context $context, array $items): array { + $pages = $context->getPages() ?? []; + $nodes = $context->getNodes() ?? []; + return array_values(array_filter($items, static fn (MenuItem $item): bool + => ($item->getPageId() === null || isset($pages[$item->getPageId()])) + && ($item->getNodeRelId() === null || isset($nodes[$item->getNodeRelId()])))); + } + + /** + * @return TablesMenuItem + * @throws NotFoundError + * @throws Exception + */ + private function serializeOne(Context $context, MenuItem $item): array { + return $this->serialize($context, [$item])[0] ?? throw new NotFoundError('Menu item ' . $item->getId() . ' does not exist'); + } + + /** + * The visible items with the title they show: their own, else the title of the page, + * table, view or link they open. A start page without a title shows the name of the + * application. + * + * @param list $items + * @return list + * @throws Exception + */ + private function serialize(Context $context, array $items): array { + $pages = $context->getPages() ?? []; + $nodes = $context->getNodes() ?? []; + $nodeIds = [Application::NODE_TYPE_TABLE => [], Application::NODE_TYPE_VIEW => []]; + $items = $this->visibleItems($context, $items); + foreach ($items as $item) { + $node = $this->nodeOf($item, $nodes); + if ($node !== null) { + $nodeIds[$node['node_type']][] = $node['node_id']; + } + } + $nodeTitles = [ + Application::NODE_TYPE_TABLE => $this->tableMapper->findIdToTitleMap($nodeIds[Application::NODE_TYPE_TABLE]), + Application::NODE_TYPE_VIEW => $this->viewMapper->findIdToTitleMap($nodeIds[Application::NODE_TYPE_VIEW]), + ]; + + $menu = []; + foreach ($items as $item) { + $node = $this->nodeOf($item, $nodes); + $targetTitle = match (true) { + $item->getPageId() !== null => $pages[$item->getPageId()]['title'] ?? $context->getName(), + $node !== null => $nodeTitles[$node['node_type']][$node['node_id']] ?? null, + default => $item->getUrl() ?? '', + }; + if ($targetTitle === null) { + continue; + } + $menu[] = [ + 'id' => $item->getId(), + 'contextId' => $item->getContextId(), + 'parentId' => $item->getParentId(), + 'title' => $item->getTitle(), + 'displayTitle' => $item->getTitle() ?? $targetTitle, + 'icon' => $item->getIcon(), + 'description' => $item->getDescription(), + 'target' => $item->getTarget(), + ]; + } + return $menu; + } + + /** + * @param array $nodes the nodes of the application by relation id + * @return array{node_id: int, node_type: int}|null + */ + private function nodeOf(MenuItem $item, array $nodes): ?array { + $nodeRelId = $item->getNodeRelId(); + return $nodeRelId === null ? null : ($nodes[$nodeRelId] ?? null); + } + + /** + * @param array $target + * @return array{pageId: int}|array{nodeRelId: int}|array{url: string} + * @throws BadRequestError + */ + private function validateTarget(array $target, Context $context): array { + if (count($target) === 1) { + $pageId = $target['pageId'] ?? null; + if (is_int($pageId) && isset(($context->getPages() ?? [])[$pageId])) { + return ['pageId' => $pageId]; + } + $nodeRelId = $target['nodeRelId'] ?? null; + if (is_int($nodeRelId) && isset(($context->getNodes() ?? [])[$nodeRelId])) { + return ['nodeRelId' => $nodeRelId]; + } + $url = $target['url'] ?? null; + if (is_string($url)) { + return ['url' => $this->validateUrl($url)]; + } + } + throw new BadRequestError('The target of a menu item is {pageId} of a page or {nodeRelId} of a table or view of this application, or {url} of a link.'); + } + + /** + * @throws BadRequestError + */ + private function validateUrl(string $url): string { + $url = trim($url); + $scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME)); + if (strlen($url) > MenuItem::URL_MAX_LENGTH || filter_var($url, FILTER_VALIDATE_URL) === false || !in_array($scheme, ['http', 'https'], true)) { + throw new BadRequestError('A link is an http or https address of at most ' . MenuItem::URL_MAX_LENGTH . ' characters.'); + } + return $url; + } + + /** + * An item opens at most one thing, and a group needs a title. + * + * @throws BadRequestError + */ + private function assertValid(MenuItem $item): void { + $targets = array_filter([$item->getPageId(), $item->getNodeRelId(), $item->getUrl()], static fn (mixed $target): bool => $target !== null); + if (count($targets) > 1) { + throw new BadRequestError('A menu item opens a page, a table or view, or a link, not more than one.'); + } + if ($item->isGroup() && $item->getTitle() === null) { + throw new BadRequestError('A group needs a title.'); + } + } + + /** + * A title, or null for an empty one. + * + * @throws BadRequestError + */ + private function validateTitle(?string $title): ?string { + $title = trim((string)$title); + if (mb_strlen($title) > MenuItem::TITLE_MAX_LENGTH) { + throw new BadRequestError('A menu item title has at most ' . MenuItem::TITLE_MAX_LENGTH . ' characters.'); + } + return $title === '' ? null : $title; + } + + /** + * A named icon, or null for an empty one. + * + * @throws BadRequestError + */ + private function validateIcon(?string $icon): ?string { + $icon = trim((string)$icon); + if ($icon !== '' && !IconHelper::isValid($icon)) { + throw new BadRequestError('A menu item has an unknown icon: ' . $icon); + } + return $icon === '' ? null : $icon; + } + + /** + * @throws BadRequestError + */ + private function validateDescription(?string $description): ?string { + $description = trim((string)$description); + if (mb_strlen($description) > MenuItem::DESCRIPTION_MAX_LENGTH) { + throw new BadRequestError('A menu item description has at most ' . MenuItem::DESCRIPTION_MAX_LENGTH . ' characters.'); + } + return $description === '' ? null : $description; + } +} diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 5005b5efed..c5599ff6f2 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -11,6 +11,7 @@ use OCA\Tables\Db\Context; use OCA\Tables\Db\ContextMapper; +use OCA\Tables\Db\MenuItemMapper; use OCA\Tables\Db\Page; use OCA\Tables\Db\PageContentMapper; use OCA\Tables\Db\PageMapper; @@ -48,6 +49,7 @@ public function __construct( private readonly PermissionsService $permissionsService, private readonly GridWidgetTypes $gridWidgetTypes, private readonly IDBConnection $dbc, + private readonly MenuItemMapper $menuItemMapper, ) { } @@ -158,7 +160,8 @@ public function update(int $contextId, int $pageId, ?string $title, ?string $tec } /** - * Deletes a grid page with its widgets. The start page cannot be deleted. + * Deletes a grid page with its widgets and the menu items opening it. The start page + * cannot be deleted. * * @return TablesPage the deleted page * @throws BadRequestError @@ -176,6 +179,7 @@ public function delete(int $contextId, int $pageId, string $userId): array { $this->atomic(function () use ($pageId): void { $this->widgetService->deleteByPageIds([$pageId]); + $this->menuItemMapper->deleteByPageIds([$pageId]); $this->pageContentMapper->deleteByPageId($pageId); $this->pageMapper->deleteByPageId($pageId); }, $this->dbc); diff --git a/package-lock.json b/package-lock.json index 3a5f9d5939..143b8938dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tables", - "version": "2.5.0-dev.0", + "version": "2.5.0-dev.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tables", - "version": "2.5.0-dev.0", + "version": "2.5.0-dev.1", "license": "AGPL-3.0-or-later", "dependencies": { "@material-symbols/svg-400": "^0.48.1", diff --git a/package.json b/package.json index d3d93cceba..c1c3181d00 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tables", "description": "Manage data within tables.", - "version": "2.5.0-dev.0", + "version": "2.5.0-dev.1", "author": "Nextcloud GmbH and Nextcloud contributors", "bugs": { "url": "https://github.com/nextcloud/tables/issues" diff --git a/tests/unit/Controller/MenuControllerTest.php b/tests/unit/Controller/MenuControllerTest.php new file mode 100644 index 0000000000..c7c4e8f00f --- /dev/null +++ b/tests/unit/Controller/MenuControllerTest.php @@ -0,0 +1,86 @@ +menuService = $this->createMock(MenuService::class); + $this->controller = new MenuController( + $this->createMock(IRequest::class), + $this->createMock(LoggerInterface::class), + $this->createMock(IL10N::class), + 'alice', + $this->menuService, + ); + } + + public static function endpoints(): array { + $target = ['url' => 'https://nextcloud.com']; + $order = [['id' => 3, 'parentId' => null]]; + return [ + 'index' => ['findAll', [7, 'alice'], static fn (MenuController $controller) => $controller->index(7)], + 'create' => ['create', [7, 'Site', 'star', 'Ours', $target, 'alice'], static fn (MenuController $controller) => $controller->create(7, 'Site', 'star', 'Ours', $target)], + 'order' => ['saveOrder', [7, $order, 'alice'], static fn (MenuController $controller) => $controller->order(7, $order)], + 'update' => ['update', [3, '', null, null, $target, 'alice'], static fn (MenuController $controller) => $controller->update(3, '', null, null, $target)], + 'destroy' => ['delete', [3, 'alice'], static fn (MenuController $controller) => $controller->destroy(3)], + ]; + } + + /** + * @dataProvider endpoints + */ + public function testAnEndpointPassesItsArgumentsOn(string $method, array $arguments, \Closure $call): void { + $this->menuService->expects($this->once())->method($method)->with(...$arguments)->willReturn([]); + + $this->assertSame(Http::STATUS_OK, $call($this->controller)->getStatus()); + } + + public static function errors(): array { + $errors = []; + foreach (self::endpoints() as $endpoint => [$method, , $call]) { + $errors[$endpoint . ', not found'] = [$method, $call, new NotFoundError('Menu item 3 does not exist'), Http::STATUS_NOT_FOUND]; + if ($endpoint !== 'index') { + $errors[$endpoint . ', no permission'] = [$method, $call, new PermissionError('User bob cannot manage application 7'), Http::STATUS_FORBIDDEN]; + } + if ($endpoint !== 'index' && $endpoint !== 'destroy') { + $errors[$endpoint . ', bad request'] = [$method, $call, new BadRequestError('A group needs a title.'), Http::STATUS_BAD_REQUEST]; + } + } + return $errors; + } + + /** + * @dataProvider errors + */ + public function testAnErrorIsAnsweredWithItsStatus(string $method, \Closure $call, \Exception $error, int $status): void { + $this->menuService->method($method)->willThrowException($error); + + $response = $call($this->controller); + + $this->assertSame($status, $response->getStatus()); + $this->assertArrayHasKey('message', $response->getData()); + } +} diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index b2951a5578..0765e28533 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -14,6 +14,7 @@ use OCA\Tables\Db\ContextMapper; use OCA\Tables\Db\ContextNodeRelation; use OCA\Tables\Db\ContextNodeRelationMapper; +use OCA\Tables\Db\MenuItemMapper; use OCA\Tables\Db\Page; use OCA\Tables\Db\PageContentMapper; use OCA\Tables\Db\PageMapper; @@ -84,6 +85,7 @@ protected function setUp(): void { $this->createMock(ViewMapper::class), $this->widgetService, new GridWidgetTypes($l10n), + $this->createMock(MenuItemMapper::class), ); } diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 9dd2b6f031..a18c3020da 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -15,6 +15,7 @@ use OCA\Tables\Db\ContextMapper; use OCA\Tables\Db\ContextNodeRelation; use OCA\Tables\Db\ContextNodeRelationMapper; +use OCA\Tables\Db\MenuItemMapper; use OCA\Tables\Db\Page; use OCA\Tables\Db\PageContent; use OCA\Tables\Db\PageContentMapper; @@ -80,6 +81,7 @@ protected function setUp(): void { $this->createMock(ViewMapper::class), $this->widgetService, new GridWidgetTypes($l10n), + $this->createMock(MenuItemMapper::class), ); } diff --git a/tests/unit/Service/MenuServiceTest.php b/tests/unit/Service/MenuServiceTest.php new file mode 100644 index 0000000000..32616ab89c --- /dev/null +++ b/tests/unit/Service/MenuServiceTest.php @@ -0,0 +1,322 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $userHelper = $this->createMock(UserHelper::class); + $userHelper->method('getGroupIdsForUser')->willReturn([]); + $contextMapper = new ContextMapper($this->connectionAdapter, $userHelper, $this->createMock(GroupHelper::class)); + $pageMapper = new PageMapper($this->connectionAdapter); + $pageContentMapper = new PageContentMapper($this->connectionAdapter); + $widgetService = new WidgetService(new WidgetMapper($this->connectionAdapter), new WidgetSourceMapper($this->connectionAdapter), $this->connectionAdapter); + $this->menuItemMapper = new MenuItemMapper($this->connectionAdapter); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->permissionsService->method('canManageNodeById')->willReturn(true); + $this->permissionsService->method('canManageContext')->willReturnCallback(fn (): bool => $this->canManage); + $tableMapper = $this->createMock(TableMapper::class); + $tableMapper->method('findIdToTitleMap')->willReturnCallback(static fn (array $ids): array => in_array(self::TABLE, $ids, true) ? [self::TABLE => 'Projects'] : []); + + $this->contextService = new ContextService( + $contextMapper, + new ContextNodeRelationMapper($this->connectionAdapter), + $pageMapper, + $pageContentMapper, + $this->createMock(LoggerInterface::class), + $this->permissionsService, + $this->createMock(IUserManager::class), + $this->createMock(AuditLogServiceInterface::class), + $this->connectionAdapter, + $this->createMock(ShareService::class), + false, + $this->createMock(INavigationManager::class), + $this->createMock(IURLGenerator::class), + $tableMapper, + $this->createMock(ViewMapper::class), + $widgetService, + new GridWidgetTypes($l10n), + $this->menuItemMapper, + ); + $this->pageService = new PageService($contextMapper, $pageMapper, $pageContentMapper, $widgetService, $this->permissionsService, new GridWidgetTypes($l10n), $this->connectionAdapter, $this->menuItemMapper); + $this->menuService = new MenuService($contextMapper, $this->menuItemMapper, $this->permissionsService, $tableMapper, $this->createMock(ViewMapper::class), $this->connectionAdapter); + } + + protected function tearDown(): void { + foreach ($this->contextIds as $contextId) { + $this->contextService->delete($contextId, self::OWNER); + } + parent::tearDown(); + } + + public function testANewApplicationOpensItsStartPage(): void { + [$contextId, $startPageId] = $this->createApplication(); + + $menu = $this->menuService->findAll($contextId, self::OWNER); + + $this->assertSame([[ + 'id' => $menu[0]['id'], + 'contextId' => $contextId, + 'parentId' => null, + 'title' => null, + 'displayTitle' => 'Sales', + 'icon' => null, + 'description' => null, + 'target' => ['pageId' => $startPageId], + ]], $menu); + } + + public function testAnItemWithoutATitleShowsTheTitleOfWhatItOpens(): void { + [$contextId, , $pageId, $nodeRelId] = $this->createApplication(); + $this->menuService->create($contextId, null, null, null, ['pageId' => $pageId], self::OWNER); + $this->menuService->create($contextId, null, null, null, ['nodeRelId' => $nodeRelId], self::OWNER); + $this->menuService->create($contextId, null, 'star', 'Our site', ['url' => 'https://nextcloud.com'], self::OWNER); + $this->menuService->create($contextId, 'Reports', null, null, null, self::OWNER); + + $menu = $this->menuService->findAll($contextId, self::OWNER); + + $this->assertSame(['Sales', 'Overview', 'Projects', 'https://nextcloud.com', 'Reports'], array_column($menu, 'displayTitle')); + $this->assertSame(['star', 'Our site'], [$menu[3]['icon'], $menu[3]['description']]); + } + + public static function invalidItems(): array { + return [ + 'a group without a title' => [null, null, 'A group needs a title.'], + 'a page of another application' => [null, ['pageId' => -1], 'The target of a menu item'], + 'a table outside the application' => [null, ['nodeRelId' => -1], 'The target of a menu item'], + 'two targets' => [null, ['url' => 'https://nextcloud.com', 'nodeRelId' => -1], 'The target of a menu item'], + 'a link that is not http' => [null, ['url' => 'javascript:alert(1)'], 'A link is an http or https address'], + 'an unknown icon' => ['Reports', null, 'unknown icon', 'no-such-icon'], + 'a long title' => [str_repeat('a', 201), null, 'at most 200 characters'], + ]; + } + + /** + * @dataProvider invalidItems + */ + public function testAnInvalidItemIsRefused(?string $title, ?array $target, string $message, ?string $icon = null): void { + [$contextId] = $this->createApplication(); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->menuService->create($contextId, $title, $icon, null, $target, self::OWNER); + } + + public function testAnItemOpensAtMostOneThing(): void { + [$contextId, , $pageId] = $this->createApplication(); + $item = $this->menuService->create($contextId, null, null, null, ['url' => 'https://nextcloud.com'], self::OWNER); + $stored = $this->menuItemMapper->findById($item['id']); + $stored->setPageId($pageId); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('not more than one'); + (new \ReflectionMethod(MenuService::class, 'assertValid'))->invoke($this->menuService, $stored); + } + + public function testOnlyAManagerSeesAnEmptyGroup(): void { + [$contextId] = $this->createApplication(); + $this->menuService->create($contextId, 'Reports', null, null, null, self::OWNER); + + $this->assertCount(2, $this->menuService->findAll($contextId, self::OWNER)); + $this->canManage = false; + $this->assertSame(['Sales'], array_column($this->menuService->findAll($contextId, self::OWNER), 'displayTitle')); + } + + public function testAMenuHoldsAtMost200Items(): void { + [$contextId] = $this->createApplication(); + for ($i = 1; $i < MenuItem::MAX_ITEMS_PER_APPLICATION; $i++) { + $item = new MenuItem(); + $item->setContextId($contextId); + $item->setOrder($i); + $item->setTitle('Group ' . $i); + $this->menuItemMapper->insert($item); + } + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('at most 200 items'); + $this->menuService->create($contextId, 'One more', null, null, null, self::OWNER); + } + + public function testAnUpdateKeepsWhatIsLeftOutAndATargetReplacesThePreviousOne(): void { + [$contextId, , $pageId] = $this->createApplication(); + $item = $this->menuService->create($contextId, 'Site', 'star', 'Ours', ['url' => 'https://nextcloud.com'], self::OWNER); + + $updated = $this->menuService->update($item['id'], '', null, '', ['pageId' => $pageId], self::OWNER); + + $this->assertSame([null, 'Overview', 'star', null, ['pageId' => $pageId]], [$updated['title'], $updated['displayTitle'], $updated['icon'], $updated['description'], $updated['target']]); + $this->assertSame($updated, $this->menuService->findAll($contextId, self::OWNER)[1]); + } + + public function testAGroupThatHoldsItemsCannotOpenSomething(): void { + [$contextId] = $this->createApplication(); + $menu = $this->menuService->findAll($contextId, self::OWNER); + $group = $this->menuService->create($contextId, 'Reports', null, null, null, self::OWNER); + $this->menuService->saveOrder($contextId, [['id' => $group['id'], 'parentId' => null], ['id' => $menu[0]['id'], 'parentId' => $group['id']]], self::OWNER); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Move its items out first'); + $this->menuService->update($group['id'], null, null, null, ['url' => 'https://nextcloud.com'], self::OWNER); + } + + public function testTheOrderSavesPositionAndGroupsAndDeletingAGroupMovesItsItemsUp(): void { + [$contextId] = $this->createApplication(); + $start = $this->menuService->findAll($contextId, self::OWNER)[0]; + $site = $this->menuService->create($contextId, null, null, null, ['url' => 'https://nextcloud.com'], self::OWNER); + $group = $this->menuService->create($contextId, 'Links', null, null, null, self::OWNER); + + $saved = $this->menuService->saveOrder($contextId, [ + ['id' => $group['id'], 'parentId' => null], + ['id' => $site['id'], 'parentId' => $group['id']], + ['id' => $start['id'], 'parentId' => null], + ], self::OWNER); + $this->assertSame([[$group['id'], null], [$site['id'], $group['id']], [$start['id'], null]], array_map(static fn (array $item): array => [$item['id'], $item['parentId']], $saved)); + + $this->menuService->delete($group['id'], self::OWNER); + $this->assertSame([[$site['id'], null], [$start['id'], null]], array_map(static fn (array $item): array => [$item['id'], $item['parentId']], $this->menuService->findAll($contextId, self::OWNER))); + } + + public static function invalidOrders(): array { + return [ + 'an item left out' => [static fn (int $start, int $group, int $site): array => [[$group, null], [$site, null]], BadRequestError::class, 'changed by another save'], + 'an item listed twice' => [static fn (int $start, int $group, int $site): array => [[$group, null], [$site, null], [$start, null], [$start, null]], BadRequestError::class, 'listed twice'], + 'a parent that is not a group' => [static fn (int $start, int $group, int $site): array => [[$group, null], [$site, $start], [$start, null]], BadRequestError::class, 'only sit in a group'], + 'an item before its group' => [static fn (int $start, int $group, int $site): array => [[$site, $group], [$group, null], [$start, null]], BadRequestError::class, 'only sit in a group'], + 'a group in a group' => [static fn (int $start, int $group, int $site): array => [[$start, null], [$group, $group], [$site, null]], BadRequestError::class, 'only sit in a group'], + 'an item of another application' => [static fn (int $start, int $group, int $site): array => [[$group, null], [$site, null], [-1, null]], NotFoundError::class, 'is not an item of application'], + ]; + } + + /** + * @dataProvider invalidOrders + * @param \Closure(int, int, int): list $order + * @param class-string<\Throwable> $error + */ + public function testAnInvalidOrderSavesNothing(\Closure $order, string $error, string $message): void { + [$contextId] = $this->createApplication(); + $start = $this->menuService->findAll($contextId, self::OWNER)[0]['id']; + $group = $this->menuService->create($contextId, 'Links', null, null, null, self::OWNER)['id']; + $site = $this->menuService->create($contextId, null, null, null, ['url' => 'https://nextcloud.com'], self::OWNER)['id']; + $before = $this->menuService->findAll($contextId, self::OWNER); + + try { + $this->menuService->saveOrder($contextId, array_map(static fn (array $entry): array => ['id' => $entry[0], 'parentId' => $entry[1]], $order($start, $group, $site)), self::OWNER); + $this->fail('The order was saved'); + } catch (\Exception $e) { + $this->assertInstanceOf($error, $e); + $this->assertStringContainsString($message, $e->getMessage()); + } + $this->assertSame($before, $this->menuService->findAll($contextId, self::OWNER)); + } + + public function testAnItemGoesWithItsTargetAndAnItemWhoseTargetIsGoneIsLeftOut(): void { + [$contextId, $startPageId, $pageId, $nodeRelId] = $this->createApplication(); + $this->menuService->create($contextId, null, null, null, ['pageId' => $pageId], self::OWNER); + $this->menuService->create($contextId, null, null, null, ['nodeRelId' => $nodeRelId], self::OWNER); + $dangling = new MenuItem(); + $dangling->setContextId($contextId); + $dangling->setOrder(9); + $dangling->setPageId(-1); + $this->menuItemMapper->insert($dangling); + + $this->pageService->delete($contextId, $pageId, self::OWNER); + $this->contextService->removeNodeFromContextAndPages($nodeRelId); + + $this->assertSame([['pageId' => $startPageId]], array_column($this->menuService->findAll($contextId, self::OWNER), 'target')); + $this->assertCount(2, $this->menuItemMapper->findByContextId($contextId)); + $this->contextService->delete($contextId, self::OWNER); + $this->contextIds = []; + $this->assertSame([], $this->menuItemMapper->findByContextId($contextId)); + } + + public static function changes(): array { + return [ + 'create' => [static fn (MenuService $service, int $contextId, int $itemId) => $service->create($contextId, 'Reports', null, null, null, self::OWNER)], + 'update' => [static fn (MenuService $service, int $contextId, int $itemId) => $service->update($itemId, 'Home', null, null, null, self::OWNER)], + 'delete' => [static fn (MenuService $service, int $contextId, int $itemId) => $service->delete($itemId, self::OWNER)], + 'order' => [static fn (MenuService $service, int $contextId, int $itemId) => $service->saveOrder($contextId, [['id' => $itemId, 'parentId' => null]], self::OWNER)], + ]; + } + + /** + * @dataProvider changes + */ + public function testOnlyAManagerChangesTheMenu(\Closure $change): void { + [$contextId] = $this->createApplication(); + $itemId = $this->menuService->findAll($contextId, self::OWNER)[0]['id']; + $this->canManage = false; + + $this->expectException(PermissionError::class); + $change($this->menuService, $contextId, $itemId); + } + + /** + * An application "Sales" with its start page, a grid page "Overview" and a table. + * + * @return array{int, int, int, int} ids of the application, start page, grid page and node relation + */ + private function createApplication(): array { + $context = $this->contextService->create('Sales', 'tables', '', [['id' => self::TABLE, 'type' => Application::NODE_TYPE_TABLE]], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $context->getId(); + $page = $this->pageService->create($context->getId(), 'Overview', null, null, null, self::OWNER); + $context = $this->contextService->findById($context->getId(), self::OWNER); + $startPages = array_filter($context->getPages(), static fn (array $page): bool => $page['page_type'] === Page::TYPE_STARTPAGE); + return [$context->getId(), (int)array_key_first($startPages), $page['id'], (int)array_key_first($context->getNodes())]; + } +} diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php index 00774bcaf0..db206f9a40 100644 --- a/tests/unit/Service/PageServiceTest.php +++ b/tests/unit/Service/PageServiceTest.php @@ -12,6 +12,7 @@ use OCA\Tables\AppInfo\Application; use OCA\Tables\Db\Context; use OCA\Tables\Db\ContextMapper; +use OCA\Tables\Db\MenuItemMapper; use OCA\Tables\Db\Page; use OCA\Tables\Db\PageContentMapper; use OCA\Tables\Db\PageMapper; @@ -72,6 +73,7 @@ protected function setUp(): void { $this->permissionsService, new GridWidgetTypes($l10n), $this->createMock(IDBConnection::class), + $this->createMock(MenuItemMapper::class), ); } From aa0c7e30bedf19cff1255baa31989909116aeb9c Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Wed, 4 Nov 2026 10:13:13 +0100 Subject: [PATCH 2/2] chore(api): regenerate the OpenAPI spec and TypeScript types Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- openapi.json | 1340 ++++++++++++++++++++++++++++++++++ src/types/openapi/openapi.ts | 651 +++++++++++++++++ 2 files changed, 1991 insertions(+) diff --git a/openapi.json b/openapi.json index b0e4a2c6cd..d7278645e7 100644 --- a/openapi.json +++ b/openapi.json @@ -420,6 +420,89 @@ } } }, + "MenuItem": { + "type": "object", + "required": [ + "id", + "contextId", + "parentId", + "title", + "displayTitle", + "icon", + "description", + "target" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "contextId": { + "type": "integer", + "format": "int64" + }, + "parentId": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "displayTitle": { + "type": "string" + }, + "icon": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "target": { + "nullable": true, + "anyOf": [ + { + "type": "object", + "required": [ + "pageId" + ], + "properties": { + "pageId": { + "type": "integer", + "format": "int64" + } + } + }, + { + "type": "object", + "required": [ + "nodeRelId" + ], + "properties": { + "nodeRelId": { + "type": "integer", + "format": "int64" + } + } + }, + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string" + } + } + } + ] + } + } + }, "NotifyConfig": { "type": "object", "required": [ @@ -16545,6 +16628,1263 @@ } } }, + "/ocs/v2.php/apps/tables/api/2/menu": { + "get": { + "operationId": "menu-index", + "summary": "[api v2] Get the menu of an application", + "description": "A flat list in display order; the items of a group carry its id as `parentId`. An application without items opens its start page. Readers who cannot manage the application do not get empty groups.", + "tags": [ + "menu" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "contextId", + "in": "query", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Menu returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MenuItem" + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found, also when the user has no access to the application", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "menu-create", + "summary": "[api v2] Add an item to the end of the menu of an application", + "description": "The `target` is `{pageId}` of a page or `{nodeRelId}` of a table or view of the application, or `{url}` of an http or https link. Without a target the item is a group, which needs a title. Without a title an item shows the title of what it opens.", + "tags": [ + "menu" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "contextId" + ], + "properties": { + "contextId": { + "type": "integer", + "format": "int64", + "description": "ID of the application" + }, + "title": { + "type": "string", + "nullable": true, + "default": null, + "description": "Title of the item" + }, + "icon": { + "type": "string", + "nullable": true, + "default": null, + "description": "Name of the icon shown with the title" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "Description of the item" + }, + "target": { + "type": "object", + "nullable": true, + "default": null, + "description": "What the item opens", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/MenuItem" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid field, a target outside the application, or a full menu", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/menu/order": { + "put": { + "operationId": "menu-order", + "summary": "[api v2] Save the order and grouping of the menu of an application", + "description": "`items` lists every item of the menu exactly once, in display order, as `{id, parentId}`; `parentId` is a group at the top level listed earlier, or null. Either all of it is saved or nothing.", + "tags": [ + "menu" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "contextId", + "items" + ], + "properties": { + "contextId": { + "type": "integer", + "format": "int64", + "description": "ID of the application" + }, + "items": { + "type": "array", + "description": "Every item of the menu", + "items": { + "type": "object", + "required": [ + "id", + "parentId" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "parentId": { + "type": "integer", + "format": "int64", + "nullable": true + } + } + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Menu saved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MenuItem" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "An item missing or listed twice, a parent that is not a group, or the menu changed in between", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found, also for an item of another application", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/menu/{itemId}": { + "patch": { + "operationId": "menu-update", + "summary": "[api v2] Change an item of a menu", + "description": "Fields left out keep their value; an empty title, icon or description removes it. A target replaces the previous one. Position and group change only through the order.", + "tags": [ + "menu" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "nullable": true, + "default": null, + "description": "New title, empty to show the title of what the item opens" + }, + "icon": { + "type": "string", + "nullable": true, + "default": null, + "description": "New icon name, empty to remove it" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "New description, empty to remove it" + }, + "target": { + "type": "object", + "nullable": true, + "default": null, + "description": "New target", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "ID of the item", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/MenuItem" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid field or a target outside the application", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "menu-destroy", + "summary": "[api v2] Delete an item of a menu", + "description": "The items of a deleted group move to the top level.", + "tags": [ + "menu" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "itemId", + "in": "path", + "description": "ID of the item", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Item deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/MenuItem" + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/tables/api/2/public/{token}/rows": { "get": { "operationId": "public_rowocs-get-rows", diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 301ff9a878..8132ea5b8f 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1068,6 +1068,74 @@ export type paths = { readonly patch?: never; readonly trace?: never; }; + readonly "/ocs/v2.php/apps/tables/api/2/menu": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** + * [api v2] Get the menu of an application + * @description A flat list in display order; the items of a group carry its id as `parentId`. An application without items opens its start page. Readers who cannot manage the application do not get empty groups. + */ + readonly get: operations["menu-index"]; + readonly put?: never; + /** + * [api v2] Add an item to the end of the menu of an application + * @description The `target` is `{pageId}` of a page or `{nodeRelId}` of a table or view of the application, or `{url}` of an http or https link. Without a target the item is a group, which needs a title. Without a title an item shows the title of what it opens. + */ + readonly post: operations["menu-create"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/ocs/v2.php/apps/tables/api/2/menu/order": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + /** + * [api v2] Save the order and grouping of the menu of an application + * @description `items` lists every item of the menu exactly once, in display order, as `{id, parentId}`; `parentId` is a group at the top level listed earlier, or null. Either all of it is saved or nothing. + */ + readonly put: operations["menu-order"]; + readonly post?: never; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/ocs/v2.php/apps/tables/api/2/menu/{itemId}": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + readonly post?: never; + /** + * [api v2] Delete an item of a menu + * @description The items of a deleted group move to the top level. + */ + readonly delete: operations["menu-destroy"]; + readonly options?: never; + readonly head?: never; + /** + * [api v2] Change an item of a menu + * @description Fields left out keep their value; an empty title, icon or description removes it. A target replaces the previous one. Position and group change only through the order. + */ + readonly patch: operations["menu-update"]; + readonly trace?: never; + }; readonly "/ocs/v2.php/apps/tables/api/2/public/{token}/rows": { readonly parameters: { readonly query?: never; @@ -1279,6 +1347,27 @@ export type components = { readonly shareToken: string; readonly url: string; }; + readonly MenuItem: { + /** Format: int64 */ + readonly id: number; + /** Format: int64 */ + readonly contextId: number; + /** Format: int64 */ + readonly parentId: number | null; + readonly title: string | null; + readonly displayTitle: string; + readonly icon: string | null; + readonly description: string | null; + readonly target: ({ + /** Format: int64 */ + readonly pageId: number; + } | { + /** Format: int64 */ + readonly nodeRelId: number; + } | { + readonly url: string; + }) | null; + }; readonly NotifyConfig: { readonly "notify-assigned": boolean; readonly "notify-column": boolean; @@ -8945,6 +9034,568 @@ export interface operations { }; }; }; + readonly "menu-index": { + readonly parameters: { + readonly query: { + /** @description ID of the application */ + readonly contextId: number; + }; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Menu returned */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: readonly components["schemas"]["MenuItem"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description Not found, also when the user has no access to the application */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "menu-create": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": { + /** + * Format: int64 + * @description ID of the application + */ + readonly contextId: number; + /** + * @description Title of the item + * @default null + */ + readonly title?: string | null; + /** + * @description Name of the icon shown with the title + * @default null + */ + readonly icon?: string | null; + /** + * @description Description of the item + * @default null + */ + readonly description?: string | null; + /** + * @description What the item opens + * @default null + */ + readonly target?: { + readonly [key: string]: Record; + } | null; + }; + }; + }; + readonly responses: { + /** @description Item created */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["MenuItem"]; + }; + }; + }; + }; + /** @description Invalid field, a target outside the application, or a full menu */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "menu-order": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": { + /** + * Format: int64 + * @description ID of the application + */ + readonly contextId: number; + /** @description Every item of the menu */ + readonly items: readonly { + /** Format: int64 */ + readonly id: number; + /** Format: int64 */ + readonly parentId: number | null; + }[]; + }; + }; + }; + readonly responses: { + /** @description Menu saved */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: readonly components["schemas"]["MenuItem"][]; + }; + }; + }; + }; + /** @description An item missing or listed twice, a parent that is not a group, or the menu changed in between */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found, also for an item of another application */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "menu-destroy": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the item */ + readonly itemId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Item deleted */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["MenuItem"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "menu-update": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the item */ + readonly itemId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: { + readonly content: { + readonly "application/json": { + /** + * @description New title, empty to show the title of what the item opens + * @default null + */ + readonly title?: string | null; + /** + * @description New icon name, empty to remove it + * @default null + */ + readonly icon?: string | null; + /** + * @description New description, empty to remove it + * @default null + */ + readonly description?: string | null; + /** + * @description New target + * @default null + */ + readonly target?: { + readonly [key: string]: Record; + } | null; + }; + }; + }; + readonly responses: { + /** @description Item updated */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["MenuItem"]; + }; + }; + }; + }; + /** @description Invalid field or a target outside the application */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; readonly "public_rowocs-get-rows": { readonly parameters: { readonly query?: {