From 49bf61ed59e94a1a508e45b52042c050a2f393c1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 19:39:27 +0200 Subject: [PATCH 1/2] feat(applications): give applications a technical name An application can get a technical name, so it opens at a readable address like /apps/tables/app/intake instead of /apps/tables/app/28. - Migration: tables_contexts_context gets a nullable technical_name. - Rules: lowercase letters, numbers and underscores, starting with a letter (the rule of menu items and pages), unique per owner. A malformed or taken name is a 400. On update, an empty name removes it and leaving it out keeps it. - Address: /apps/tables/app/{name} opens the application. The user's own application wins over one shared with them under the same name. Because names are only unique per owner, applications of others are linked by id, in the app navigation and on the overview. An unknown name opens the Tables start page. - Dialogs: the create and edit application dialogs take an optional technical name. - Export and import: the technical name travels with a scheme. - A link in the application menu opens in a new tab. The app version moves to 2.4.0-dev.3 so installs run the migration. Assisted-by: ClaudeCode:claude-opus-5-5 --- appinfo/info.xml | 2 +- lib/Controller/ContextController.php | 13 ++- lib/Controller/PageController.php | 17 ++- lib/Db/Context.php | 4 + lib/Db/ContextMapper.php | 48 ++++++++ .../Version2400Date20261015120000.php | 42 +++++++ lib/Model/ContextScheme.php | 12 +- lib/ResponseDefinitions.php | 1 + lib/Service/ContextService.php | 41 ++++++- openapi.json | 23 ++++ playwright/e2e/application.spec.ts | 18 +++ .../main/sections/ApplicationOverview.vue | 12 +- src/modules/modals/CreateContext.vue | 10 ++ src/modules/modals/EditContext.vue | 11 ++ .../sections/ApplicationNavigation.vue | 2 + src/pages/Context.vue | 4 +- src/types/openapi/openapi.ts | 16 +++ tests/unit/Controller/PageControllerTest.php | 73 +++++++++++++ .../Db/ContextMapperTechnicalNameTest.php | 103 ++++++++++++++++++ tests/unit/Model/ContextSchemeTest.php | 15 +++ tests/unit/Service/ContextServiceTest.php | 96 +++++++++++++++- 21 files changed, 541 insertions(+), 22 deletions(-) create mode 100644 lib/Migration/Version2400Date20261015120000.php create mode 100644 tests/unit/Controller/PageControllerTest.php create mode 100644 tests/unit/Db/ContextMapperTechnicalNameTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 8698fe6963..7e3355396e 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.2 + 2.4.0-dev.3 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php index 02bfacaec3..278e06a690 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, and an item opening the start page is put first unless the menu has one + * @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 * @psalm-param ?list $menuItems Menu of the context: tables and views by uuid, pages by their id in the scheme, parents by technical name; leave it out to keep the current menu * @psalm-param ?list> $pages Pages of the context with their widgets (a scheme of an older version keys them by id); data widgets name their node by its id in the nodes; leave it out to keep the current pages * @psalm-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 $menuItems = null, ?array $pages = null, array $gridViews = []): DataResponse { + public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menuItems = null, ?array $pages = 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 0041f1ef2a..8e9aa5798a 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -22,6 +22,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 @@ -38,6 +40,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; @@ -61,6 +64,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 04e17114df..3c57290458 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -147,6 +147,7 @@ protected function formatResultRows(array $rows, ?string $userId, array $nodes, '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) { @@ -209,6 +210,53 @@ protected function formatResultRows(array $rows, ?string $userId, array $nodes, 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/Version2400Date20261015120000.php b/lib/Migration/Version2400Date20261015120000.php new file mode 100644 index 0000000000..5e9952700e --- /dev/null +++ b/lib/Migration/Version2400Date20261015120000.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 d37107acbe..58ee0f4fb7 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 a1c9a7b38f..d55f6c4fcb 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 f540d86912..1ec2e0b45b 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -105,7 +105,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(), @@ -141,13 +143,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); @@ -451,6 +454,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) ?? ''; @@ -464,11 +488,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, ); } @@ -480,12 +504,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)); } @@ -1108,6 +1135,8 @@ public function getScheme(int $contextId, string $userId): ContextScheme { $schemeService->exportPages($contextId, $userId), array_values($tables), $schemeService->exportMenuItems($context), + [], + $context->getTechnicalName(), ); } @@ -1266,7 +1295,7 @@ public function importScheme(int $contextId, ContextScheme $scheme, string $user } } - $context = $this->update($contextId, $userId, $scheme->getName(), $scheme->getIcon() ?? '', $scheme->getDescription() ?? '', $resolvedNodes); + $context = $this->update($contextId, $userId, $scheme->getName(), $scheme->getIcon() ?? '', $scheme->getDescription() ?? '', $resolvedNodes, null, $scheme->getTechnicalName()); // Pages come after the nodes their data widgets show, the menu after the pages it opens $schemeService = $this->contextSchemeService(); diff --git a/openapi.json b/openapi.json index 778b243212..d576baef1c 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": { @@ -12327,6 +12332,12 @@ } } } + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "names the context in its address, lowercase letters, numbers and underscores; unique per owner" } } } @@ -12803,6 +12814,12 @@ } } } + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "provide this parameter to set a new technical name; an empty one removes it" } } } @@ -13901,6 +13918,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 eccf5285a6..bb9f85a7cd 100644 --- a/playwright/e2e/application.spec.ts +++ b/playwright/e2e/application.spec.ts @@ -166,6 +166,24 @@ 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 } }) => { + // Technical names are unique per owner; a fresh one keeps reruns on the same instance apart + const technicalName = 'intake_' + Date.now() + await page.goto('/index.php/apps/tables') + await createContext(page, 'named application') + await loadContext(page, 'named application') + + 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) + + 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('A page with widgets added on the overview gets a menu entry and opens in edit mode', async ({ userPage: { page } }) => { await page.goto('/index.php/apps/tables') await createContext(page, 'overview application') diff --git a/src/modules/main/sections/ApplicationOverview.vue b/src/modules/main/sections/ApplicationOverview.vue index 7ad565b440..ff8f30fb69 100644 --- a/src/modules/main/sections/ApplicationOverview.vue +++ b/src/modules/main/sections/ApplicationOverview.vue @@ -403,8 +403,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) diff --git a/src/modules/modals/CreateContext.vue b/src/modules/modals/CreateContext.vue index 2084e2777b..3cec84d4d2 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 24640d4f94..4afcca8564 100644 --- a/src/modules/modals/EditContext.vue +++ b/src/modules/modals/EditContext.vue @@ -30,6 +30,13 @@
+
+
+ {{ t('tables', 'Technical name') }} +
+ +
{{ t('tables', 'Resources') }} @@ -122,6 +129,7 @@ export default { svg: null, }, description: '', + technicalName: '', errorTitle: false, resources: [], receivers: [], @@ -152,6 +160,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) @@ -185,6 +194,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 @@ -210,6 +220,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 bc4aa53841..387b44a8a7 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">