Skip to content
Closed
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
4 changes: 2 additions & 2 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +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
* @psalm-param ?list<array{label: string, icon?: string, targetType: 'view'|'table'|'url', targetId?: int, url?: string, technicalName?: string, parentIndex?: int}> $menuItems optional menu of the context, in order; parentIndex is the position of the parent item, for one level of nesting; 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 Down Expand Up @@ -141,7 +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
* @psalm-param ?list<array{label: string, icon?: string, targetType: 'view'|'table'|'url', targetId?: int, url?: string, technicalName?: string, parentIndex?: int}> $menuItems provide this parameter to replace the menu; parentIndex is the position of the parent item, for one level of nesting; 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 Down
7 changes: 6 additions & 1 deletion lib/Db/MenuItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
* @method setTechnicalName(string $technicalName): void
* @method getOrder(): int
* @method setOrder(int $order): void
* @method getParentId(): ?int
* @method setParentId(?int $parentId): void
* @method isBroken(): bool
* @method setBroken(bool $broken): void
*/
Expand All @@ -45,6 +47,7 @@ class MenuItem extends EntitySuper implements JsonSerializable {
protected ?string $url = null;
protected ?string $technicalName = null;
protected ?int $order = null;
protected ?int $parentId = null;

// virtual: true when the view or table the item points at no longer exists
protected bool $broken = false;
Expand All @@ -56,11 +59,12 @@ public function __construct() {
$this->addType('contextId', 'integer');
$this->addType('targetId', 'integer');
$this->addType('order', 'integer');
$this->addType('parentId', 'integer');
$this->addType('broken', 'boolean');
}

/**
* @return array{id: int, contextId: int, label: string, icon: string|null, targetType: 'view'|'table'|'url', targetId: int|null, url: string|null, technicalName: string, order: int, broken: bool}
* @return array{id: int, contextId: int, label: string, icon: string|null, targetType: 'view'|'table'|'url', targetId: int|null, url: string|null, technicalName: string, order: int, parentId: int|null, broken: bool}
*/
public function jsonSerialize(): array {
return [
Expand All @@ -77,6 +81,7 @@ public function jsonSerialize(): array {
'url' => $this->getUrl(),
'technicalName' => $this->technicalName ?? '',
'order' => $this->getOrder() ?? 0,
'parentId' => $this->getParentId(),
'broken' => $this->broken,
];
}
Expand Down
40 changes: 40 additions & 0 deletions lib/Migration/Version2400Date20261012030000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

/**
* A menu item may sit under another one, one level deep.
*/
class Version2400Date20261012030000 extends SimpleMigrationStep {
#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

if ($schema->hasTable('tables_contexts_menu_item')) {
$table = $schema->getTable('tables_contexts_menu_item');
if (!$table->hasColumn('parent_id')) {
$table->addColumn('parent_id', Types::INTEGER, [
'notnull' => false,
'default' => null,
]);
}
}

return $schema;
}
}
27 changes: 26 additions & 1 deletion lib/Model/MenuItemInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public function __construct(
private readonly ?int $targetId,
private readonly ?string $url,
private readonly ?string $technicalName,
private readonly ?int $parentIndex = null,
) {
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.');
Expand Down Expand Up @@ -74,6 +75,10 @@ public static function createFromInputArray(mixed $data): self {
throw new InvalidArgumentException('The menu item property "' . $optional . '" must be a string.');
}
}
$parentIndex = $data['parentIndex'] ?? null;
if ($parentIndex !== null && (!is_int($parentIndex) || $parentIndex < 0)) {
throw new InvalidArgumentException('The parent of a menu item is given as the position of that parent in the menu.');
}

return new self(
label: trim($data['label']),
Expand All @@ -82,18 +87,31 @@ public static function createFromInputArray(mixed $data): self {
targetId: $isUrl ? null : $targetId,
url: $isUrl ? trim((string)($data['url'] ?? '')) : null,
technicalName: self::emptyToNull($data['technicalName'] ?? null),
parentIndex: $parentIndex,
);
}

/**
* A menu with one level of nesting: an item may sit under a top-level item of the same menu.
*
* @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);
$items = array_map(self::createFromInputArray(...), $data);
foreach ($items as $item) {
$parentIndex = $item->getParentIndex();
if ($parentIndex === null) {
continue;
}
if (!isset($items[$parentIndex]) || $items[$parentIndex]->getParentIndex() !== null) {
throw new InvalidArgumentException('A menu item can only sit under a top-level item of the same menu.');
}
}
return $items;
}

/**
Expand Down Expand Up @@ -148,4 +166,11 @@ public function getUrl(): ?string {
public function getTechnicalName(): ?string {
return $this->technicalName;
}

/**
* The position of the parent item in the same menu, or null for a top-level item.
*/
public function getParentIndex(): ?int {
return $this->parentIndex;
}
}
1 change: 1 addition & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@
* url: string|null,
* technicalName: string,
* order: int,
* parentId: int|null,
* broken: bool,
* }
*
Expand Down
37 changes: 26 additions & 11 deletions lib/Service/ContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,32 @@ protected function replaceMenu(Context $context, array $menuItems, ?string $user

$this->menuItemMapper->deleteAllByContextId($context->getId());
$technicalNames = $this->menuTechnicalNames($menuItems, $storedMenu);
foreach ($menuItems as $position => $item) {
$menuItem = new MenuItem();
$menuItem->setContextId($context->getId());
$menuItem->setLabel($item->getLabel());
$menuItem->setIcon($item->getIcon());
$menuItem->setTargetType($item->getTargetType());
$menuItem->setTargetId($item->getTargetId());
$menuItem->setUrl($item->getUrl());
$menuItem->setTechnicalName($technicalNames[$position]);
$menuItem->setOrder(($position + 1) * 10);
$this->menuItemMapper->insert($menuItem);
$topLevelIds = [];
// top-level items first, so a child can point at its parent's id; the order keeps the menu's sequence
foreach ([false, true] as $childPass) {
foreach ($menuItems as $position => $item) {
$parentIndex = $item->getParentIndex();
if (($parentIndex !== null) !== $childPass) {
continue;
}
if ($parentIndex !== null && !isset($topLevelIds[$parentIndex])) {
throw new InvalidArgumentException('A menu item can only sit under a top-level item of the same menu.');
}
$menuItem = new MenuItem();
$menuItem->setContextId($context->getId());
$menuItem->setLabel($item->getLabel());
$menuItem->setIcon($item->getIcon());
$menuItem->setTargetType($item->getTargetType());
$menuItem->setTargetId($item->getTargetId());
$menuItem->setUrl($item->getUrl());
$menuItem->setTechnicalName($technicalNames[$position]);
$menuItem->setOrder(($position + 1) * 10);
$menuItem->setParentId($parentIndex === null ? null : $topLevelIds[$parentIndex]);
$storedId = $this->menuItemMapper->insert($menuItem)->getId();
if ($parentIndex === null) {
$topLevelIds[$position] = $storedId;
}
}
}

$context->setMenuItems(array_map(
Expand Down
18 changes: 16 additions & 2 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@
"url",
"technicalName",
"order",
"parentId",
"broken"
],
"properties": {
Expand Down Expand Up @@ -469,6 +470,11 @@
"type": "integer",
"format": "int64"
},
"parentId": {
"type": "integer",
"format": "int64",
"nullable": true
},
"broken": {
"type": "boolean"
}
Expand Down Expand Up @@ -12081,7 +12087,7 @@
"type": "array",
"nullable": true,
"default": null,
"description": "optional menu of the context, in order; the views and tables it opens become nodes of the context",
"description": "optional menu of the context, in order; parentIndex is the position of the parent item, for one level of nesting; the views and tables it opens become nodes of the context",
"items": {
"type": "object",
"required": [
Expand Down Expand Up @@ -12112,6 +12118,10 @@
},
"technicalName": {
"type": "string"
},
"parentIndex": {
"type": "integer",
"format": "int64"
}
}
}
Expand Down Expand Up @@ -12552,7 +12562,7 @@
"type": "array",
"nullable": true,
"default": null,
"description": "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",
"description": "provide this parameter to replace the menu; parentIndex is the position of the parent item, for one level of nesting; 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",
"items": {
"type": "object",
"required": [
Expand Down Expand Up @@ -12583,6 +12593,10 @@
},
"technicalName": {
"type": "string"
},
"parentIndex": {
"type": "integer",
"format": "int64"
}
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/types/openapi/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1202,6 +1202,8 @@ export type components = {
readonly technicalName: string;
/** Format: int64 */
readonly order: number;
/** Format: int64 */
readonly parentId: number | null;
readonly broken: boolean;
};
readonly NotifyConfig: {
Expand Down Expand Up @@ -6920,7 +6922,7 @@ export interface operations {
readonly permissions?: number;
}[];
/**
* @description optional menu of the context, in order; the views and tables it opens become nodes of the context
* @description optional menu of the context, in order; parentIndex is the position of the parent item, for one level of nesting; the views and tables it opens become nodes of the context
* @default null
*/
readonly menuItems?: readonly {
Expand All @@ -6932,6 +6934,8 @@ export interface operations {
readonly targetId?: number;
readonly url?: string;
readonly technicalName?: string;
/** Format: int64 */
readonly parentIndex?: number;
}[] | null;
};
};
Expand Down Expand Up @@ -7124,7 +7128,7 @@ export interface operations {
readonly order: number;
} | null;
/**
* @description 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
* @description provide this parameter to replace the menu; parentIndex is the position of the parent item, for one level of nesting; 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
* @default null
*/
readonly menuItems?: readonly {
Expand All @@ -7136,6 +7140,8 @@ export interface operations {
readonly targetId?: number;
readonly url?: string;
readonly technicalName?: string;
/** Format: int64 */
readonly parentIndex?: number;
}[] | null;
};
};
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/Db/MenuItemTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function testJsonSerializeUsesSafeDefaults(): void {
'url' => null,
'technicalName' => '',
'order' => 0,
'parentId' => null,
'broken' => false,
], $item->jsonSerialize());
}
Expand All @@ -41,6 +42,7 @@ public function testJsonSerializeCarriesEveryField(): void {
$item->setTargetId(6);
$item->setTechnicalName('home');
$item->setOrder(20);
$item->setParentId(4);

$json = $item->jsonSerialize();

Expand All @@ -50,6 +52,7 @@ public function testJsonSerializeCarriesEveryField(): void {
$this->assertSame(6, $json['targetId']);
$this->assertSame('home', $json['technicalName']);
$this->assertSame(20, $json['order']);
$this->assertSame(4, $json['parentId']);
}

public static function targetTypeProvider(): array {
Expand Down
35 changes: 35 additions & 0 deletions tests/unit/Model/MenuItemInputTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,41 @@ public function testRejectsInvalidInput(mixed $input, string $message): void {
MenuItemInput::createFromInputArray($input);
}

public function testKeepsTheParentPositionForOneLevelOfNesting(): void {
$items = MenuItemInput::createListFromInputArray([
['label' => 'Cases', 'targetType' => 'table', 'targetId' => 4],
['label' => 'Open', 'targetType' => 'view', 'targetId' => 6, 'parentIndex' => 0],
['label' => 'Docs', 'url' => 'https://example.org', 'parentIndex' => null],
]);

$this->assertSame([null, 0, null], array_map(static fn (MenuItemInput $item) => $item->getParentIndex(), $items));
}

public static function invalidNesting(): array {
return [
'under a child' => [[null, 0, 1], 'top-level'],
'under itself' => [[null, 1], 'top-level'],
'under a position that does not exist' => [[null, 5], 'top-level'],
'given as a string' => [[null, '0'], 'position of that parent'],
'given as a negative number' => [[null, -1], 'position of that parent'],
];
}

/**
* @dataProvider invalidNesting
*/
public function testRejectsInvalidNesting(array $parentIndexes, string $message): void {
$items = array_map(
static fn (mixed $parentIndex, int $position): array => ['label' => 'Item ' . $position, 'url' => 'https://example.org/' . $position, 'parentIndex' => $parentIndex],
$parentIndexes,
array_keys($parentIndexes),
);

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage($message);
MenuItemInput::createListFromInputArray($items);
}

public function testRejectsAMenuThatIsNotAList(): void {
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('must be a list');
Expand Down
Loading