diff --git a/appinfo/info.xml b/appinfo/info.xml index 774f0db234..8197d0f581 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -26,7 +26,7 @@ Share your tables and views with users and groups within your cloud. Have a good time and manage whatever you want. ]]> - 2.4.0-dev.7 + 2.4.0-dev.8 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index 2b2557e325..d44aa3c2a2 100644 --- a/lib/Controller/ContextController.php +++ b/lib/Controller/ContextController.php @@ -103,6 +103,7 @@ public function show(int $contextId): DataResponse { * @param string $description Descriptive text of the context * @psalm-param list $nodes optional nodes to be connected to this context * @psalm-param ?list $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 + * @param ?string $technicalName names the context in its address, lowercase letters, numbers and underscores; unique per owner * * @return DataResponse|DataResponse * @@ -111,7 +112,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 = [], ?array $menuItems = null): DataResponse { + public function create(string $name, string $iconName, string $description = '', array $nodes = [], ?array $menuItems = null, ?string $technicalName = null): DataResponse { try { if (!$this->isValidIcon($iconName)) { return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST); @@ -124,6 +125,7 @@ public function create(string $name, string $iconName, string $description = '', $this->userId, 0, $menuItems !== null ? MenuItemInput::createListFromInputArray($menuItems, allowNewStartPage: true) : null, + $technicalName, )->jsonSerialize()); } catch (Exception $e) { return $this->handleError($e); @@ -143,6 +145,7 @@ public function create(string $name, string $iconName, string $description = '', * @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 $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 + * @param ?string $technicalName provide this parameter to set a new technical name; an empty one removes it * * @return DataResponse|DataResponse * @@ -154,7 +157,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, ?array $menuItems = null): DataResponse { + public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?array $menuItems = null, ?string $technicalName = null): DataResponse { try { if ($iconName !== null && !$this->isValidIcon($iconName)) { return new DataResponse(['message' => 'Invalid icon name'], Http::STATUS_BAD_REQUEST); @@ -168,6 +171,7 @@ public function update(int $contextId, ?string $name, ?string $iconName, ?string $description, $nodes, $menuItems !== null ? MenuItemInput::createListFromInputArray($menuItems) : null, + $technicalName, )->jsonSerialize()); } catch (Exception|MultipleObjectsReturnedException $e) { return $this->handleError($e); @@ -365,6 +369,7 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR * @param list>|null $pages Pages of the scheme; each updates the page with the same technical name or is added, with its widgets; leave it out to keep the current pages * @psalm-param ?list $menuItems Menu of the context: tables and views by uuid, pages by technical name, parents by technical name; leave it out to keep the current menu * @param list> $gridViews Views without a table from a scheme of an older version; each becomes a page + * @param ?string $technicalName technical name of the context; leave it out to keep the current one * * @return DataResponse, array{}>|DataResponse * @@ -375,9 +380,9 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR */ #[NoAdminRequired] #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] - public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $pages = null, ?array $menuItems = null, array $gridViews = []): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $pages = null, ?array $menuItems = null, array $gridViews = [], ?string $technicalName = null): DataResponse { try { - $scheme = ContextScheme::createFromInputArray(['name' => $name, 'icon' => $iconName, 'description' => $description, 'nodes' => $nodes, 'tables' => $tables, 'menuItems' => $menuItems, 'pages' => $pages, 'gridViews' => $gridViews]); + $scheme = ContextScheme::createFromInputArray(['name' => $name, 'icon' => $iconName, 'description' => $description, 'nodes' => $nodes, 'tables' => $tables, 'menuItems' => $menuItems, 'pages' => $pages, 'gridViews' => $gridViews, 'technicalName' => $technicalName]); } catch (\InvalidArgumentException $e) { return $this->handleBadRequestError(new BadRequestError($e->getMessage(), $e->getCode(), $e)); } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index c4c6cb41e9..441e766ab4 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -8,6 +8,7 @@ namespace OCA\Tables\Controller; use OCA\Tables\AppInfo\Application; +use OCA\Tables\Db\ContextMapper; use OCA\Tables\Service\NodeService; use OCA\Tables\Service\ShareService; use OCA\Text\Event\LoadEditor; @@ -32,6 +33,8 @@ public function __construct( protected IInitialState $initialState, protected ShareService $shareService, protected NodeService $nodeService, + protected ContextMapper $contextMapper, + protected ?string $userId, ) { parent::__construct(Application::APP_ID, $request); } @@ -58,18 +61,20 @@ public function index(): TemplateResponse { } /** - * Render default template - * - * @psalm-param int<0, max> $appId + * Render the template for one application, addressed by id or by technical name */ #[NoAdminRequired] #[NoCSRFRequired] #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] - public function context(int $contextId): TemplateResponse { - $navId = Application::APP_ID . '_application_' . $contextId; + public function context(string $contextId): TemplateResponse { + $id = ctype_digit($contextId) ? (int)$contextId : ($this->userId === null ? null : $this->contextMapper->findIdByTechnicalName($contextId, $this->userId)); + if ($id === null) { + return $this->index(); + } + $navId = Application::APP_ID . '_application_' . $id; $this->navigationManager->setActiveEntry($navId); - $this->initialState->provideInitialState('contextId', $contextId); + $this->initialState->provideInitialState('contextId', $id); return $this->index(); } diff --git a/lib/Db/Context.php b/lib/Db/Context.php index 333cbfda3e..9c433e89b9 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -21,6 +21,8 @@ * @method setOwnerId(string $value): void * @method getOwnerType(): int * @method setOwnerType(int $value): void + * @method getTechnicalName(): ?string + * @method setTechnicalName(?string $value): void * * @method getSharing(): array * @method setSharing(array $value): void @@ -37,6 +39,7 @@ class Context extends EntitySuper implements JsonSerializable { protected ?string $description = null; protected ?string $ownerId = null; protected ?int $ownerType = null; + protected ?string $technicalName = null; // virtual properties protected ?array $sharing = null; @@ -60,6 +63,7 @@ public function jsonSerialize(): array { 'description' => $this->getDescription(), 'owner' => $this->getOwnerId(), 'ownerType' => $this->getOwnerType(), + 'technicalName' => $this->getTechnicalName(), 'menuItems' => array_values($this->menuItems ?? []), ]; diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 6354f0e830..1f092b3dd3 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -88,6 +88,7 @@ protected function formatResultRows(array $rows, ?string $userId, array $menuIte 'description' => $rows[0]['description'], 'owner_id' => $rows[0]['owner_id'], 'owner_type' => $rows[0]['owner_type'], + 'technical_name' => $rows[0]['technical_name'] ?? null, ]; $formatted['sharing'] = array_reduce($rows, function (array $carry, array $item) use ($userId) { @@ -157,6 +158,53 @@ protected function formatResultRows(array $rows, ?string $userId, array $menuIte return $this->mapRowToEntity($formatted); } + /** + * The id of the context with this technical name that the user owns, else the oldest one + * shared with them. Technical names are unique per owner only. + * + * @throws Exception + */ + public function findIdByTechnicalName(string $technicalName, string $userId): ?int { + $qb = $this->db->getQueryBuilder(); + $qb->selectDistinct(['c.id', 'c.owner_id']) + ->from($this->table, 'c'); + $this->applyOwnedOrSharedQuery($qb, $userId); + $qb->andWhere($qb->expr()->eq('c.technical_name', $qb->createNamedParameter($technicalName, IQueryBuilder::PARAM_STR))) + ->orderBy('c.id', 'ASC'); + $result = $qb->executeQuery(); + $sharedId = null; + while (($row = $result->fetchAssociative()) !== false) { + if ($row['owner_id'] === $userId) { + $result->closeCursor(); + return (int)$row['id']; + } + $sharedId ??= (int)$row['id']; + } + $result->closeCursor(); + return $sharedId; + } + + /** + * Whether the owner already has another context with this technical name. + * + * @throws Exception + */ + public function isTechnicalNameTaken(string $technicalName, string $ownerId, ?int $exceptContextId = null): bool { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from($this->table) + ->where($qb->expr()->eq('technical_name', $qb->createNamedParameter($technicalName, IQueryBuilder::PARAM_STR))) + ->andWhere($qb->expr()->eq('owner_id', $qb->createNamedParameter($ownerId, IQueryBuilder::PARAM_STR))) + ->setMaxResults(1); + if ($exceptContextId !== null) { + $qb->andWhere($qb->expr()->neq('id', $qb->createNamedParameter($exceptContextId, IQueryBuilder::PARAM_INT))); + } + $result = $qb->executeQuery(); + $taken = $result->fetchOne() !== false; + $result->closeCursor(); + return $taken; + } + /** * @return Context[] * @throws Exception diff --git a/lib/Migration/Version2400Date20261016120000.php b/lib/Migration/Version2400Date20261016120000.php new file mode 100644 index 0000000000..d4172648c9 --- /dev/null +++ b/lib/Migration/Version2400Date20261016120000.php @@ -0,0 +1,42 @@ +hasTable('tables_contexts_context')) { + $table = $schema->getTable('tables_contexts_context'); + if (!$table->hasColumn('technical_name')) { + $table->addColumn('technical_name', Types::STRING, [ + 'notnull' => false, + 'default' => null, + 'length' => 200, + ]); + } + } + + return $schema; + } +} diff --git a/lib/Model/ContextScheme.php b/lib/Model/ContextScheme.php index 6a07aa675e..686eca3f93 100644 --- a/lib/Model/ContextScheme.php +++ b/lib/Model/ContextScheme.php @@ -21,6 +21,7 @@ public function __construct( protected ?array $tables = [], protected ?array $menuItems = [], protected array $gridViews = [], + protected ?string $technicalName = null, ) { } @@ -43,7 +44,7 @@ public static function createFromInputArray(array $data): self { if (!is_string($data['name'] ?? null) || trim($data['name']) === '') { throw new InvalidArgumentException('A scheme needs a name.'); } - foreach (['icon', 'description'] as $optional) { + foreach (['icon', 'description', 'technicalName'] as $optional) { if (isset($data[$optional]) && !is_string($data[$optional])) { throw new InvalidArgumentException('The scheme property "' . $optional . '" must be a string.'); } @@ -64,6 +65,7 @@ public static function createFromInputArray(array $data): self { tables: $tables, menuItems: isset($data['menuItems']) ? array_values($data['menuItems']) : null, gridViews: array_values($data['gridViews'] ?? []), + technicalName: isset($data['technicalName']) && $data['technicalName'] !== '' ? $data['technicalName'] : null, ); } @@ -89,6 +91,13 @@ private static function assertGridViewsValid(array $gridViews): void { } } + /** + * Null when the scheme gives none, which leaves the technical name of the application alone. + */ + public function getTechnicalName(): ?string { + return $this->technicalName; + } + public function getName(): ?string { return $this->name ?? ''; } @@ -142,6 +151,7 @@ public function jsonSerialize(): mixed { 'pages' => $this->pages, 'tables' => $this->tables, 'menuItems' => $this->menuItems, + 'technicalName' => $this->technicalName, ]; } } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index bc115c0a8b..1490ecc42a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -249,6 +249,7 @@ * description: string, * owner: string, * ownerType: int, + * technicalName: string|null, * menuItems: list, * } * diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index ae93bfc718..fe2ca61554 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -108,7 +108,9 @@ public function addToNavigation(string $userId): void { $iconUrl = $this->urlGenerator->imagePath('core', 'places/default-app-icon.svg'); } - $contextUrl = $this->urlGenerator->linkToRoute('tables.page.context', ['contextId' => $context->getId()]); + // Technical names are unique per owner only, so applications of others are linked by id + $isOwner = $context->getOwnerType() === Application::OWNER_TYPE_USER && $context->getOwnerId() === $userId; + $contextUrl = $this->urlGenerator->linkToRoute('tables.page.context', ['contextId' => ($isOwner ? $context->getTechnicalName() : null) ?: $context->getId()]); $this->navigationManager->add([ 'id' => Application::APP_ID . '_application_' . $context->getId(), @@ -144,13 +146,14 @@ public function findById(int $id, ?string $userId): Context { * @param list|null $menuItems * @throws Exception|PermissionError|InvalidArgumentException */ - public function create(string $name, string $iconName, string $description, array $nodes, string $ownerId, int $ownerType, ?array $menuItems = null): Context { + public function create(string $name, string $iconName, string $description, array $nodes, string $ownerId, int $ownerType, ?array $menuItems = null, ?string $technicalName = null): Context { $context = new Context(); $context->setName(trim($name)); $context->setIcon(trim($iconName)); $context->setDescription(trim($description)); $context->setOwnerId($ownerId); $context->setOwnerType($ownerType); + $context->setTechnicalName($this->normalizeTechnicalName($technicalName, $ownerId, null)); $this->atomic(function () use ($context, $nodes, $menuItems, $ownerId): void { $this->contextMapper->insert($context); @@ -445,6 +448,27 @@ private function addMenuTargetsAsNodes(Context $context, array $menuItems, array $context->setPages($pages); } + /** + * The technical name of an application as it is stored: trimmed, and null when blank. + * + * @throws InvalidArgumentException when the name is malformed or the owner already uses it + * @throws Exception + */ + protected function normalizeTechnicalName(?string $technicalName, string $ownerId, ?int $contextId): ?string { + if ($technicalName === null) { + return null; + } + $technicalName = trim($technicalName); + if ($technicalName === '') { + return null; + } + MenuItemInput::assertTechnicalNameValid($technicalName); + if ($this->contextMapper->isTechnicalNameTaken($technicalName, $ownerId, $contextId)) { + throw new InvalidArgumentException('An application with the technical name "' . $technicalName . '" already exists.'); + } + return $technicalName; + } + protected function technicalNameFromLabel(string $label): string { $name = strtolower(trim(preg_replace('/[^a-z0-9]+/i', '_', $label) ?? '', '_')); $name = preg_replace('/^[^a-z]+/', '', $name) ?? ''; @@ -458,11 +482,11 @@ protected function technicalNameFromLabel(string $label): string { * @throws DoesNotExistException * @throws PermissionError|MultipleObjectsReturnedException|InvalidArgumentException */ - public function update(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?array $menuItems = null): Context { + public function update(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?array $menuItems = null, ?string $technicalName = null): Context { // A transaction that reads before it writes can lose a write lock to a concurrent request // (SQLite answers "database is locked" at once); running it again then succeeds return $this->atomicRetry( - fn (): Context => $this->applyUpdate($contextId, $userId, $name, $iconName, $description, $nodes, $menuItems), + fn (): Context => $this->applyUpdate($contextId, $userId, $name, $iconName, $description, $nodes, $menuItems, $technicalName), $this->dbc, ); } @@ -474,12 +498,15 @@ public function update(int $contextId, string $userId, ?string $name, ?string $i * @throws DoesNotExistException * @throws PermissionError|MultipleObjectsReturnedException|InvalidArgumentException */ - private function applyUpdate(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?array $menuItems): Context { + private function applyUpdate(int $contextId, string $userId, ?string $name, ?string $iconName, ?string $description, ?array $nodes, ?array $menuItems, ?string $technicalName): Context { $context = $this->contextMapper->findById($contextId, $userId); if ($name !== null) { $context->setName(trim($name)); } + if ($technicalName !== null) { + $context->setTechnicalName($this->normalizeTechnicalName($technicalName, $context->getOwnerId(), $contextId)); + } if ($iconName !== null) { $context->setIcon(trim($iconName)); } @@ -1176,6 +1203,8 @@ public function getScheme(int $contextId, string $userId): ContextScheme { $this->getPagesWithWidgets($context), array_values($tables), $this->contextSchemeService->exportMenuItems($context), + [], + $context->getTechnicalName(), ); } @@ -1343,7 +1372,7 @@ public function importScheme(int $contextId, ContextScheme $scheme, string $user } } - $this->update($contextId, $userId, $scheme->getName(), $scheme->getIcon() ?? '', $scheme->getDescription() ?? '', $resolvedNodes); + $this->update($contextId, $userId, $scheme->getName(), $scheme->getIcon() ?? '', $scheme->getDescription() ?? '', $resolvedNodes, null, $scheme->getTechnicalName()); if ($pages !== null) { $this->importSchemePages($contextId, $nodes, $pages); } diff --git a/openapi.json b/openapi.json index a39371e556..e8ede2b4cf 100644 --- a/openapi.json +++ b/openapi.json @@ -280,6 +280,7 @@ "description", "owner", "ownerType", + "technicalName", "menuItems" ], "properties": { @@ -303,6 +304,10 @@ "type": "integer", "format": "int64" }, + "technicalName": { + "type": "string", + "nullable": true + }, "menuItems": { "type": "array", "items": { @@ -12338,6 +12343,12 @@ } } } + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "names the context in its address, lowercase letters, numbers and underscores; unique per owner" } } } @@ -12814,6 +12825,12 @@ } } } + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "provide this parameter to set a new technical name; an empty one removes it" } } } @@ -13911,6 +13928,12 @@ "type": "object" } } + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "technical name of the context; leave it out to keep the current one" } } } diff --git a/playwright/e2e/application.spec.ts b/playwright/e2e/application.spec.ts index 507493a3f8..401e06b70d 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -181,6 +181,25 @@ test.describe('Applications', () => { await expect(page.locator('[data-cy="page-card"]')).toContainText('Invoices') }) + test('An application with a technical name is opened at its readable address', async ({ userPage: { page, user } }) => { + // Technical names are unique per owner; a fresh one keeps reruns on the same instance apart + const technicalName = 'intake_' + Date.now() + const contextId = await createEmptyApplication(page.request, user, 'named application') + await page.goto(`/index.php/apps/tables/#/application/${contextId}/overview`) + + await page.getByRole('button', { name: 'Application settings' }).click() + await page.getByRole('menuitem', { name: 'Edit name, icon and members' }).click() + await page.locator('[data-cy="editContextTechnicalName"]').fill(technicalName) + await page.locator('[data-cy="editContextSubmitBtn"]').click() + await expect(page.locator('[data-cy="context-address"]')).toContainText('/apps/tables/app/' + technicalName) + // Pages carry their own technical name, shown next to their title + await expect(page.locator('[data-cy="page-card-technical-name"]')).toHaveText(['dashboard']) + + await page.goto('/index.php/apps/tables/app/' + technicalName) + await expect(page.locator('[data-cy="application-nav-header"]')).toContainText('named application') + await expect(page).toHaveURL(/\/menu\/dashboard$/) + }) + test('The overview lists the pages; a page added there gets a menu entry and opens in edit mode', async ({ userPage: { page, user } }) => { const contextResponse = await ocsRequest(page.request, user, { method: 'POST', url: `${API}/contexts?format=json`, data: { name: 'overview application', iconName: 'star' } }) const contextId = (await contextResponse.json()).ocs.data.id diff --git a/src/modules/main/sections/ApplicationOverview.vue b/src/modules/main/sections/ApplicationOverview.vue index dcc4a7946a..e1abc7e898 100644 --- a/src/modules/main/sections/ApplicationOverview.vue +++ b/src/modules/main/sections/ApplicationOverview.vue @@ -152,6 +152,7 @@ {{ row.title }} + {{ row.page.technical_name }} {{ row.typeLabel }} @@ -401,8 +402,18 @@ export default { }, computed: { ...mapState(useTablesStore, ['tables', 'views']), + /** + * The address of the application opened on its own. Technical names are unique per owner + * only, so an application of someone else is addressed by id. + * + * @return {string} + */ applicationUrl() { - return window.location.origin + generateUrl('/apps/tables/app/' + this.context.id) + const technicalName = this.isOwner ? this.context.technicalName : null + return window.location.origin + generateUrl('/apps/tables/app/' + (technicalName || this.context.id)) + }, + isOwner() { + return this.context.owner === getCurrentUser()?.uid }, menuItems() { return [...(this.context.menuItems ?? [])].sort((a, b) => a.order - b.order) @@ -954,6 +965,11 @@ export default { text-align: center; } + &__technical-name { + color: var(--color-text-maxcontrast); + font-size: 0.9em; + } + &__link { color: inherit; font-weight: bold; diff --git a/src/modules/modals/CreateContext.vue b/src/modules/modals/CreateContext.vue index 2084e2777b..9e52548567 100644 --- a/src/modules/modals/CreateContext.vue +++ b/src/modules/modals/CreateContext.vue @@ -36,6 +36,13 @@ +
+
+ {{ t('tables', 'Technical name') }} +
+ +
{{ t('tables', 'Resources') }} @@ -118,6 +125,7 @@ export default { customTitleChosen: false, errorTitle: false, description: '', + technicalName: '', menuItems: [newDashboardItem(t('tables', 'Dashboard'))], resources: [], receivers: [], @@ -197,6 +205,7 @@ export default { iconName: this.icon.name, description: this.description, nodes: dataResources, + technicalName: this.technicalName.trim() || null, menuItems: this.menuItems.map(item => toMenuItemPayload(item, this.menuItems)), } // adding share to oneself to have navigation display control @@ -221,6 +230,7 @@ export default { }, reset() { this.title = '' + this.technicalName = '' this.menuItems = [newDashboardItem(t('tables', 'Dashboard'))] this.errorTitle = false this.setIcon(this.randomIcon()) diff --git a/src/modules/modals/EditContext.vue b/src/modules/modals/EditContext.vue index b096360b74..073af75ca9 100644 --- a/src/modules/modals/EditContext.vue +++ b/src/modules/modals/EditContext.vue @@ -30,6 +30,13 @@
+
+
+ {{ t('tables', 'Technical name') }} +
+ +
{{ t('tables', 'Resources') }} @@ -114,6 +121,7 @@ export default { svg: null, }, description: '', + technicalName: '', errorTitle: false, resources: [], receivers: [], @@ -144,6 +152,7 @@ export default { this.title = context.name this.setIcon(this.localContext.iconName) this.description = context.description + this.technicalName = context.technicalName ?? '' this.resources = context ? this.getContextResources(context) : [] this.receivers = context ? this.getContextReceivers(context) : [] this.showInNavigationDefault = this.getNavDisplay(context) @@ -177,6 +186,7 @@ export default { iconName: this.icon.name, description: this.description, nodes: dataResources, + technicalName: this.technicalName.trim(), } const context = this.getContext(this.contextId) // adding share to oneself to have navigation display control @@ -202,6 +212,7 @@ export default { this.errorTitle = false this.icon.name = 'equalizer' this.description = '' + this.technicalName = context?.technicalName ?? '' this.resources = context ? this.getContextResources(context) : [] this.receivers = context ? this.getContextReceivers(context) : [] this.prepareDeleteContext = false diff --git a/src/modules/navigation/sections/ApplicationNavigation.vue b/src/modules/navigation/sections/ApplicationNavigation.vue index dfda1fab18..4d7a0aa9ec 100644 --- a/src/modules/navigation/sections/ApplicationNavigation.vue +++ b/src/modules/navigation/sections/ApplicationNavigation.vue @@ -19,6 +19,7 @@ :name="branch.item.label" :to="branch.item.targetType === 'url' ? undefined : menuItemRoute(branch.item, context.id)" :href="branch.item.targetType === 'url' ? branch.item.url : undefined" + :target="branch.item.targetType === 'url' ? '_blank' : undefined" :class="{ active: isActive(branch.item) }" :allow-collapse="branch.children.length > 0" :open="branch.children.some(child => isActive(child))" @@ -32,6 +33,7 @@ :name="child.label" :to="child.targetType === 'url' ? undefined : menuItemRoute(child, context.id)" :href="child.targetType === 'url' ? child.url : undefined" + :target="child.targetType === 'url' ? '_blank' : undefined" :class="{ active: isActive(child) }" data-cy="application-nav-item">