Skip to content
13 changes: 10 additions & 3 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use OCA\Tables\Errors\NotFoundError;
use OCA\Tables\Errors\PermissionError;
use OCA\Tables\Middleware\Attribute\RequirePermission;
use OCA\Tables\Model\MenuItemInput;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\ColumnService;
use OCA\Tables\Service\ContextService;
Expand Down Expand Up @@ -100,6 +101,7 @@ public function show(int $contextId): DataResponse {
* @param string $iconName Material design icon name of the context
* @param string $description Descriptive text of the context
* @psalm-param list<array{id: int, type: int, permissions?: int}> $nodes optional nodes to be connected to this context
* @psalm-param ?list<array{label: string, icon?: string, targetType: 'view'|'table'|'url'|'page', targetId?: int, url?: string, technicalName?: string}> $menuItems optional menu of the context, in order; the views and tables it opens become nodes of the context, a page item without a targetId opens the start page, and an item opening the start page is put first unless the menu has one
*
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN, array{message: string}, array{}>
*
Expand All @@ -108,7 +110,7 @@ public function show(int $contextId): DataResponse {
* 403: lacking permissions on a resource
*/
#[NoAdminRequired]
public function create(string $name, string $iconName, string $description = '', array $nodes = []): DataResponse {
public function create(string $name, string $iconName, string $description = '', array $nodes = [], ?array $menuItems = null): DataResponse {
try {
if (!$this->isValidIcon($iconName)) {
return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST);
Expand All @@ -120,13 +122,14 @@ public function create(string $name, string $iconName, string $description = '',
$this->sanitizeInputNodes($nodes),
$this->userId,
0,
$menuItems !== null ? MenuItemInput::createListFromInputArray($menuItems, allowNewStartPage: true) : null,
)->jsonSerialize());
} catch (Exception $e) {
return $this->handleError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (InvalidArgumentException $e) {
return $this->handleError(new InternalError($e->getMessage(), $e->getCode(), $e));
return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e));
}
}

Expand All @@ -138,6 +141,7 @@ public function create(string $name, string $iconName, string $description = '',
* @param ?string $iconName provide this parameter to set a new icon
* @param ?string $description provide this parameter to set a new description
* @param ?array{id: int, type: int, permissions: int, order: int} $nodes provide this parameter to set a new list of nodes.
* @psalm-param ?list<array{label: string, icon?: string, targetType: 'view'|'table'|'url'|'page', targetId?: int, url?: string, technicalName?: string}> $menuItems provide this parameter to replace the menu; the views and tables it opens become nodes of the context, and an item without a technicalName keeps the one stored for the same target
*
* @return DataResponse<Http::STATUS_OK, TablesContext, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND|Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
Expand All @@ -149,7 +153,7 @@ public function create(string $name, string $iconName, string $description = '',
* @CanManageContext
*/
#[NoAdminRequired]
public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes): DataResponse {
public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?array $menuItems = null): DataResponse {
try {
if ($iconName !== null && !$this->isValidIcon($iconName)) {
return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST);
Expand All @@ -162,13 +166,16 @@ public function update(int $contextId, ?string $name, ?string $iconName, ?string
$iconName,
$description,
$nodes,
$menuItems !== null ? MenuItemInput::createListFromInputArray($menuItems) : null,
)->jsonSerialize());
} catch (Exception|MultipleObjectsReturnedException $e) {
return $this->handleError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (DoesNotExistException $e) {
return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e));
} catch (InvalidArgumentException $e) {
return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e));
}
}

Expand Down
173 changes: 173 additions & 0 deletions lib/Model/MenuItemInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Model;

use InvalidArgumentException;
use OCA\Tables\Db\MenuItem;

class MenuItemInput {
public const MAX_LABEL_LENGTH = 200;
public const MAX_ICON_LENGTH = 64;
public const MAX_URL_LENGTH = 2000;
public const MAX_TECHNICAL_NAME_LENGTH = 200;
public const TECHNICAL_NAME_PATTERN = '/^[a-z][a-z0-9_]*$/';
private const URL_SCHEMES = ['http', 'https'];

/**
* @throws InvalidArgumentException
*/
public function __construct(
private readonly string $label,
private readonly ?string $icon,
private readonly string $targetType,
private readonly ?int $targetId,
private readonly ?string $url,
private readonly ?string $technicalName,
private readonly bool $allowNewStartPage = false,
) {
if (trim($this->label) === '' || mb_strlen($this->label) > self::MAX_LABEL_LENGTH) {
throw new InvalidArgumentException('A menu item needs a label of at most ' . self::MAX_LABEL_LENGTH . ' characters.');
}
if ($this->icon !== null && mb_strlen($this->icon) > self::MAX_ICON_LENGTH) {
throw new InvalidArgumentException('A menu item icon name must not exceed ' . self::MAX_ICON_LENGTH . ' characters.');
}
if (!in_array($this->targetType, MenuItem::TARGET_TYPES, true)) {
throw new InvalidArgumentException('Unexpected menu item target type, expected one of: ' . implode(', ', MenuItem::TARGET_TYPES) . '.');
}
if ($this->targetType === MenuItem::TARGET_URL) {
self::assertUrlValid($this->url);
} elseif (!$this->pointsAtNewStartPage() && ($this->targetId === null || $this->targetId <= 0)) {
throw new InvalidArgumentException('A menu item pointing at a ' . $this->targetType . ' needs its id.');
}
if ($this->technicalName !== null) {
self::assertTechnicalNameValid($this->technicalName);
}
}

/**
* @param bool $allowNewStartPage accept a page item without an id, meaning the start page of
* an application that is being created
* @throws InvalidArgumentException
*/
public static function createFromInputArray(mixed $data, bool $allowNewStartPage = false): self {
if (!is_array($data)) {
throw new InvalidArgumentException('A menu item must be an object.');
}
if (!is_string($data['label'] ?? null)) {
throw new InvalidArgumentException('A menu item needs a label.');
}
$targetType = $data['targetType'] ?? MenuItem::TARGET_URL;
if (!is_string($targetType)) {
throw new InvalidArgumentException('The target type of a menu item must be a string.');
}
$isUrl = $targetType === MenuItem::TARGET_URL;
$targetId = $data['targetId'] ?? null;
$isNewStartPage = $allowNewStartPage && $targetType === MenuItem::TARGET_PAGE && $targetId === null;
if (!$isUrl && !$isNewStartPage && !is_int($targetId)) {
throw new InvalidArgumentException('A menu item pointing at a ' . $targetType . ' needs its id.');
}
foreach (['icon', 'url', 'technicalName'] as $optional) {
if (isset($data[$optional]) && !is_string($data[$optional])) {
throw new InvalidArgumentException('The menu item property "' . $optional . '" must be a string.');
}
}

return new self(
label: trim($data['label']),
icon: self::emptyToNull($data['icon'] ?? null),
targetType: $targetType,
targetId: $isUrl ? null : $targetId,
url: $isUrl ? trim((string)($data['url'] ?? '')) : null,
technicalName: self::emptyToNull($data['technicalName'] ?? null),
allowNewStartPage: $allowNewStartPage,
);
}

/**
* @param bool $allowNewStartPage see createFromInputArray()
* @return list<self>
* @throws InvalidArgumentException
*/
public static function createListFromInputArray(mixed $data, bool $allowNewStartPage = false): array {
if (!is_array($data) || !array_is_list($data)) {
throw new InvalidArgumentException('The menu items must be a list.');
}
return array_map(static fn (mixed $item): self => self::createFromInputArray($item, $allowNewStartPage), $data);
}

/**
* True for a page item without an id on a menu of an application that is being created.
*/
public function pointsAtNewStartPage(): bool {
return $this->allowNewStartPage && $this->targetType === MenuItem::TARGET_PAGE && $this->targetId === null;
}

/**
* This item pointing at the given page.
*
* @throws InvalidArgumentException
*/
public function withPageId(int $pageId): self {
return new self($this->label, $this->icon, MenuItem::TARGET_PAGE, $pageId, null, $this->technicalName);
}

/**
* @throws InvalidArgumentException
*/
public static function assertTechnicalNameValid(string $technicalName): void {
if (strlen($technicalName) > self::MAX_TECHNICAL_NAME_LENGTH || !preg_match(self::TECHNICAL_NAME_PATTERN, $technicalName)) {
throw new InvalidArgumentException('A technical name must start with a lowercase letter and contain only lowercase letters, numbers and underscores.');
}
}

/**
* @throws InvalidArgumentException
*/
private static function assertUrlValid(?string $url): void {
Comment thread
remko48 marked this conversation as resolved.
$scheme = $url === null ? null : parse_url($url, PHP_URL_SCHEME);
if ($url === null || mb_strlen($url) > self::MAX_URL_LENGTH
|| !is_string($scheme) || !in_array(strtolower($scheme), self::URL_SCHEMES, true)
|| parse_url($url, PHP_URL_HOST) === null) {
throw new InvalidArgumentException('A menu item link must be an http or https address of at most ' . self::MAX_URL_LENGTH . ' characters.');
}
}

private static function emptyToNull(?string $value): ?string {
if ($value === null) {
return null;
}
$value = trim($value);
return $value === '' ? null : $value;
}

public function getLabel(): string {
return $this->label;
}

public function getIcon(): ?string {
return $this->icon;
}

public function getTargetType(): string {
return $this->targetType;
}

public function getTargetId(): ?int {
return $this->targetId;
}

public function getUrl(): ?string {
return $this->url;
}

public function getTechnicalName(): ?string {
return $this->technicalName;
}
}
Loading
Loading