Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

]]></description>
<version>2.5.0-dev.0</version>
<version>2.5.0-dev.1</version>
<licence>AGPL-3.0-or-later</licence>
<author>Nextcloud GmbH and Nextcloud contributors</author>
<namespace>Tables</namespace>
Expand Down
200 changes: 200 additions & 0 deletions lib/Controller/MenuController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Controller;

use OCA\Tables\AppInfo\Application;
use OCA\Tables\Errors\BadRequestError;
use OCA\Tables\Errors\NotFoundError;
use OCA\Tables\Errors\PermissionError;
use OCA\Tables\Middleware\Attribute\RequirePermission;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\MenuService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\DB\Exception;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;

/**
* @psalm-import-type TablesMenuItem from ResponseDefinitions
*/
class MenuController extends AOCSController {
public function __construct(
IRequest $request,
LoggerInterface $logger,
IL10N $n,
string $userId,
private readonly MenuService $menuService,
) {
parent::__construct($request, $logger, $n, $userId);
}

/**
* [api v2] Get the menu of an application
*
* 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.
*
* @param int $contextId ID of the application
* @return DataResponse<Http::STATUS_OK, list<TablesMenuItem>, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 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<string, mixed>|null $target What the item opens
* @return DataResponse<Http::STATUS_OK, TablesMenuItem, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 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<array{id: int, parentId: ?int}> $items Every item of the menu
* @return DataResponse<Http::STATUS_OK, list<TablesMenuItem>, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 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<string, mixed>|null $target New target
* @return DataResponse<Http::STATUS_OK, TablesMenuItem, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 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<Http::STATUS_OK, TablesMenuItem, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 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);
}
}
}
88 changes: 88 additions & 0 deletions lib/Db/MenuItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Db;

use OCP\AppFramework\Db\Entity;

/**
* An item of the menu of an application. It opens at most one thing: a page of the
* application (`page_id`), a table or view of the application (`node_rel_id`), or a
* link (`url`); at most one of the three is set, which MenuService enforces. An item
* that opens nothing is a group; groups are top-level and only groups hold items.
*
* @method getContextId(): int
* @method setContextId(int $contextId): void
* @method getParentId(): ?int
* @method setParentId(?int $parentId): void
* @method getOrder(): int
* @method setOrder(int $order): void
* @method getTitle(): ?string
* @method setTitle(?string $title): void
* @method getIcon(): ?string
* @method setIcon(?string $icon): void
* @method getDescription(): ?string
* @method setDescription(?string $description): void
* @method getPageId(): ?int
* @method setPageId(?int $pageId): void
* @method getNodeRelId(): ?int
* @method setNodeRelId(?int $nodeRelId): void
* @method getUrl(): ?string
* @method setUrl(?string $url): void
*/
class MenuItem extends Entity {
public const MAX_ITEMS_PER_APPLICATION = 200;
public const TITLE_MAX_LENGTH = 200;
public const DESCRIPTION_MAX_LENGTH = 2000;
public const URL_MAX_LENGTH = 2000;

protected ?int $contextId = null;
protected ?int $parentId = null;
protected ?int $order = null;
protected ?string $title = null;
protected ?string $icon = null;
protected ?string $description = null;
protected ?int $pageId = null;
protected ?int $nodeRelId = null;
protected ?string $url = null;

public function __construct() {
$this->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);
}
}
Loading
Loading