Repository navigation
feat(contexts): save the menu of an application #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+1,196
−11
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
b78bbed
feat(contexts): save the menu of an application
remko48 4ad3b27
Merge branch 'stack/m1-menu-model' into stack/m2-menu-saving
remko48 c304fd4
fix(contexts): answer unusable menu targets alike and keep stored tec…
remko48 0299c48
fix(contexts): retry an application update that loses its write lock
remko48 02fa6ad
Merge branch 'stack/m1-menu-model' into stack/m2-menu-saving
remko48 c2146f0
Merge branch 'stack/m1-menu-model' into stack/m2-menu-saving
rubenvdlinde be888f1
feat(menu): save page targets and give new applications a dashboard item
rubenvdlinde cff2554
fix(menu): keep a stored page item whose page is gone
rubenvdlinde File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| $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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.