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
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', 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
*
* @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) : 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', 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
151 changes: 151 additions & 0 deletions lib/Model/MenuItemInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?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,
) {
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->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);
}
}

/**
* @throws InvalidArgumentException
*/
public static function createFromInputArray(mixed $data): 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;
if (!$isUrl && !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),
);
}

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

/**
* @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 {
$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