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
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'|'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; without a menu, the context gets one item opening the start page
* @psalm-param ?list<array{label: string, icon?: string, targetType: 'view'|'table'|'url'|'page', 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, a page item without a targetId opens the start page; without a menu, the context gets one item opening the start page
*
* @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'|'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
* @psalm-param ?list<array{label: string, icon?: string, targetType: 'view'|'table'|'url'|'page', 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 @@ -46,6 +48,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, table or page the item points at no longer exists
protected bool $broken = false;
Expand All @@ -57,11 +60,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'|'page', 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'|'page', targetId: int|null, url: string|null, technicalName: string, order: int, parentId: int|null, broken: bool}
*/
public function jsonSerialize(): array {
return [
Expand All @@ -79,6 +83,7 @@ public function jsonSerialize(): array {
'url' => $this->getUrl(),
'technicalName' => $this->technicalName ?? '',
'order' => $this->getOrder() ?? 0,
'parentId' => $this->getParentId(),
Comment thread
remko48 marked this conversation as resolved.
'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;
}
}
29 changes: 27 additions & 2 deletions 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,
private readonly bool $allowNewStartPage = false,
) {
if (trim($this->label) === '' || mb_strlen($this->label) > self::MAX_LABEL_LENGTH) {
Expand Down Expand Up @@ -78,6 +79,10 @@ public static function createFromInputArray(mixed $data, bool $allowNewStartPage
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 @@ -86,11 +91,14 @@ public static function createFromInputArray(mixed $data, bool $allowNewStartPage
targetId: $isUrl ? null : $targetId,
url: $isUrl ? trim((string)($data['url'] ?? '')) : null,
technicalName: self::emptyToNull($data['technicalName'] ?? null),
parentIndex: $parentIndex,
allowNewStartPage: $allowNewStartPage,
);
}

/**
* A menu with one level of nesting: an item may sit under a top-level item of the same menu.
*
* @param bool $allowNewStartPage see createFromInputArray()
* @return list<self>
* @throws InvalidArgumentException
Expand All @@ -99,7 +107,17 @@ public static function createListFromInputArray(mixed $data, bool $allowNewStart
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);
$items = array_map(static fn (mixed $item): self => self::createFromInputArray($item, $allowNewStartPage), $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 All @@ -115,7 +133,7 @@ public function pointsAtNewStartPage(): bool {
* @throws InvalidArgumentException
*/
public function withPageId(int $pageId): self {
return new self($this->label, $this->icon, MenuItem::TARGET_PAGE, $pageId, null, $this->technicalName);
return new self($this->label, $this->icon, MenuItem::TARGET_PAGE, $pageId, null, $this->technicalName, $this->parentIndex);
}

/**
Expand Down Expand Up @@ -170,4 +188,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 @@ -246,17 +246,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 @@ -470,6 +471,11 @@
"type": "integer",
"format": "int64"
},
"parentId": {
"type": "integer",
"format": "int64",
"nullable": true
},
"broken": {
"type": "boolean"
}
Expand Down Expand Up @@ -12082,7 +12088,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, a page item without a targetId opens the start page; without a menu, the context gets one item opening the start page",
"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, a page item without a targetId opens the start page; without a menu, the context gets one item opening the start page",
"items": {
"type": "object",
"required": [
Expand Down Expand Up @@ -12114,6 +12120,10 @@
},
"technicalName": {
"type": "string"
},
"parentIndex": {
"type": "integer",
"format": "int64"
}
}
}
Expand Down Expand Up @@ -12554,7 +12564,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 @@ -12586,6 +12596,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, a page item without a targetId opens the start page; without a menu, the context gets one item opening the start page
* @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, a page item without a targetId opens the start page; without a menu, the context gets one item opening the start page
* @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
Loading
Loading