{{ 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', '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">
diff --git a/src/pages/Context.vue b/src/pages/Context.vue
index 16554eca24..4cf3975840 100644
--- a/src/pages/Context.vue
+++ b/src/pages/Context.vue
@@ -234,7 +234,9 @@ export default {
return this.standaloneContextId !== null
},
applicationUrl() {
- return window.location.origin + generateUrl('/apps/tables/app/' + this.activeContext?.id)
+ // Technical names are unique per owner only, so an application of someone else is addressed by id
+ const technicalName = this.activeContext && this.ownsContext(this.activeContext) ? this.activeContext.technicalName : null
+ return window.location.origin + generateUrl('/apps/tables/app/' + (technicalName || this.activeContext?.id))
},
menuItems() {
return [...(this.activeContext?.menuItems ?? [])].sort((a, b) => a.order - b.order)
diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts
index d720c439af..3a8480acbd 100644
--- a/src/types/openapi/openapi.ts
+++ b/src/types/openapi/openapi.ts
@@ -1199,6 +1199,7 @@ export type components = {
readonly owner: string;
/** Format: int64 */
readonly ownerType: number;
+ readonly technicalName: string | null;
readonly menuItems: readonly components["schemas"]["MenuItem"][];
};
readonly ContextNavigation: {
@@ -7041,6 +7042,11 @@ export interface operations {
/** Format: int64 */
readonly parentIndex?: number;
}[] | null;
+ /**
+ * @description names the context in its address, lowercase letters, numbers and underscores; unique per owner
+ * @default null
+ */
+ readonly technicalName?: string | null;
};
};
};
@@ -7247,6 +7253,11 @@ export interface operations {
/** Format: int64 */
readonly parentIndex?: number;
}[] | null;
+ /**
+ * @description provide this parameter to set a new technical name; an empty one removes it
+ * @default null
+ */
+ readonly technicalName?: string | null;
};
};
};
@@ -7711,6 +7722,11 @@ export interface operations {
readonly gridViews?: readonly {
readonly [key: string]: Record;
}[];
+ /**
+ * @description technical name of the context; leave it out to keep the current one
+ * @default null
+ */
+ readonly technicalName?: string | null;
};
};
};
diff --git a/tests/unit/Controller/PageControllerTest.php b/tests/unit/Controller/PageControllerTest.php
new file mode 100644
index 0000000000..b58c78f3a2
--- /dev/null
+++ b/tests/unit/Controller/PageControllerTest.php
@@ -0,0 +1,73 @@
+initialState = $this->createMock(IInitialState::class);
+ $this->contextMapper = $this->createMock(ContextMapper::class);
+ }
+
+ private function controller(?string $userId): PageController {
+ return new PageController(
+ $this->createMock(IRequest::class),
+ $this->createMock(IEventDispatcher::class),
+ $this->createMock(INavigationManager::class),
+ $this->initialState,
+ $this->createMock(ShareService::class),
+ $this->createMock(NodeService::class),
+ $this->contextMapper,
+ $userId,
+ );
+ }
+
+ public function testAnApplicationIsOpenedByItsId(): void {
+ $this->contextMapper->expects($this->never())->method('findIdByTechnicalName');
+ $this->initialState->expects($this->once())->method('provideInitialState')->with('contextId', 28);
+
+ $this->controller('alice')->context('28');
+ }
+
+ public function testAnApplicationIsOpenedByItsTechnicalName(): void {
+ $this->contextMapper->expects($this->once())->method('findIdByTechnicalName')->with('intake', 'alice')->willReturn(28);
+ $this->initialState->expects($this->once())->method('provideInitialState')->with('contextId', 28);
+
+ $this->controller('alice')->context('intake');
+ }
+
+ public function testAnUnknownTechnicalNameOpensTheStartPageOfTables(): void {
+ $this->contextMapper->method('findIdByTechnicalName')->willReturn(null);
+ $this->initialState->expects($this->never())->method('provideInitialState');
+
+ $this->controller('alice')->context('nothing_here');
+ }
+
+ public function testATechnicalNameIsNotLookedUpWithoutAUser(): void {
+ $this->contextMapper->expects($this->never())->method('findIdByTechnicalName');
+ $this->initialState->expects($this->never())->method('provideInitialState');
+
+ $this->controller(null)->context('intake');
+ }
+}
diff --git a/tests/unit/Db/ContextMapperTechnicalNameTest.php b/tests/unit/Db/ContextMapperTechnicalNameTest.php
new file mode 100644
index 0000000000..a37421fb0d
--- /dev/null
+++ b/tests/unit/Db/ContextMapperTechnicalNameTest.php
@@ -0,0 +1,103 @@
+cleanupTablesData();
+ $userHelper = $this->createMock(UserHelper::class);
+ $userHelper->method('getGroupIdsForUser')->willReturn([]);
+ $this->contextMapper = new ContextMapper(
+ $this->connectionAdapter,
+ $userHelper,
+ $this->createMock(GroupHelper::class),
+ new MenuItemMapper($this->connectionAdapter),
+ );
+ }
+
+ public function testTheOwnApplicationWinsOverOneSharedWithTheSameName(): void {
+ $shared = $this->createContext('bob', 'intake');
+ $this->shareWith($shared, 'bob', 'alice');
+ $own = $this->createContext('alice', 'intake');
+
+ $this->assertSame($own, $this->contextMapper->findIdByTechnicalName('intake', 'alice'));
+ $this->assertSame($shared, $this->contextMapper->findIdByTechnicalName('intake', 'bob'));
+ }
+
+ public function testAnApplicationSharedWithTheUserIsFoundByItsName(): void {
+ $shared = $this->createContext('bob', 'intake');
+ $this->shareWith($shared, 'bob', 'alice');
+
+ $this->assertSame($shared, $this->contextMapper->findIdByTechnicalName('intake', 'alice'));
+ }
+
+ public function testAnApplicationOfSomeoneElseIsNotFound(): void {
+ $this->createContext('bob', 'intake');
+
+ $this->assertNull($this->contextMapper->findIdByTechnicalName('intake', 'alice'));
+ $this->assertNull($this->contextMapper->findIdByTechnicalName('reports', 'bob'));
+ }
+
+ public function testANameIsTakenPerOwnerAndNotByTheApplicationItself(): void {
+ $intake = $this->createContext('alice', 'intake');
+
+ $this->assertTrue($this->contextMapper->isTechnicalNameTaken('intake', 'alice'));
+ $this->assertFalse($this->contextMapper->isTechnicalNameTaken('intake', 'alice', $intake));
+ $this->assertFalse($this->contextMapper->isTechnicalNameTaken('intake', 'bob'));
+ }
+
+ private function createContext(string $ownerId, ?string $technicalName): int {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->insert('tables_contexts_context')
+ ->values([
+ 'name' => $qb->createNamedParameter('Intake'),
+ 'icon' => $qb->createNamedParameter('star'),
+ 'description' => $qb->createNamedParameter(''),
+ 'owner_id' => $qb->createNamedParameter($ownerId),
+ 'owner_type' => $qb->createNamedParameter(0),
+ 'technical_name' => $qb->createNamedParameter($technicalName),
+ ])
+ ->executeStatement();
+ return (int)$this->connection->lastInsertId();
+ }
+
+ private function shareWith(int $contextId, string $sender, string $receiver): void {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->insert('tables_shares')
+ ->values([
+ 'sender' => $qb->createNamedParameter($sender),
+ 'receiver' => $qb->createNamedParameter($receiver),
+ 'receiver_type' => $qb->createNamedParameter('user'),
+ 'node_id' => $qb->createNamedParameter($contextId),
+ 'node_type' => $qb->createNamedParameter('context'),
+ 'permission_read' => $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL),
+ 'permission_create' => $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL),
+ 'permission_update' => $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL),
+ 'permission_delete' => $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL),
+ 'permission_manage' => $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL),
+ 'created_at' => $qb->createNamedParameter(date('Y-m-d H:i:s')),
+ 'last_edit_at' => $qb->createNamedParameter(date('Y-m-d H:i:s')),
+ ])
+ ->executeStatement();
+ }
+}
diff --git a/tests/unit/Model/ContextSchemeTest.php b/tests/unit/Model/ContextSchemeTest.php
index 5a1b6d5484..87b8f86f4c 100644
--- a/tests/unit/Model/ContextSchemeTest.php
+++ b/tests/unit/Model/ContextSchemeTest.php
@@ -35,6 +35,21 @@ public function testCreateFromInputCarriesEveryPart(): void {
$this->assertSame(self::TABLES, $scheme->getTables());
}
+ public function testCreateFromInputCarriesTheTechnicalNameAndTreatsABlankOneAsNone(): void {
+ $named = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'technicalName' => 'intake']);
+ $blank = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'technicalName' => '']);
+
+ $this->assertSame('intake', $named->getTechnicalName());
+ $this->assertSame('intake', $named->jsonSerialize()['technicalName']);
+ $this->assertNull($blank->getTechnicalName());
+ }
+
+ public function testCreateFromInputRejectsATechnicalNameThatIsNotAString(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('"technicalName" must be a string');
+ ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'technicalName' => ['intake']]);
+ }
+
public function testCreateFromInputKeepsAnAbsentMenuOrPagesApartFromEmptyOnes(): void {
$without = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES]);
$empty = ContextScheme::createFromInputArray(['name' => 'Intake', 'tables' => self::TABLES, 'menuItems' => [], 'pages' => []]);
diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php
index 61b67c15f5..74604e00f9 100644
--- a/tests/unit/Service/ContextServiceTest.php
+++ b/tests/unit/Service/ContextServiceTest.php
@@ -53,6 +53,7 @@ final class ContextServiceTest extends TestCase {
private MockObject $permissionsService;
private ContextSchemeService&MockObject $contextSchemeService;
private array $constructorArgs;
+ private MockObject $urlGenerator;
protected function setUp(): void {
parent::setUp();
@@ -74,7 +75,7 @@ protected function setUp(): void {
$gridL10n->method('t')->willReturnArgument(0);
$shareService = $this->createMock(ShareService::class);
$navigationManager = $this->createMock(INavigationManager::class);
- $urlGenerator = $this->createMock(IURLGenerator::class);
+ $this->urlGenerator = $this->createMock(IURLGenerator::class);
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnCallback(static fn (string $text): string => 'translated ' . $text);
@@ -92,7 +93,7 @@ protected function setUp(): void {
$shareService,
false,
$navigationManager,
- $urlGenerator,
+ $this->urlGenerator,
$this->tableMapper,
$this->viewMapper,
$this->widgetService,
@@ -136,6 +137,96 @@ private function scheme(array $nodes, ?array $pages, ?array $menuItems, array $g
return new ContextScheme('Intake', 'star', '', $nodes, $pages, ['addTables' => [], 'modifyTables' => []], $menuItems, $gridViews);
}
+ public function testUpdateRejectsAMalformedTechnicalName(): void {
+ $this->contextMapper->method('findById')->willReturn($this->contextWithMenu(7, []));
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->service->update(7, 'user-1', null, null, null, null, null, 'Not A Name');
+ }
+
+ public function testUpdateRejectsATechnicalNameTheOwnerAlreadyUses(): void {
+ $this->contextMapper->method('findById')->willReturn($this->contextWithMenu(7, []));
+ $this->contextMapper->method('isTechnicalNameTaken')->with('intake', 'user-1', 7)->willReturn(true);
+
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('already exists');
+ $this->service->update(7, 'user-1', null, null, null, null, null, 'intake');
+ }
+
+ public function testUpdateClearsTheTechnicalNameWhenItIsBlank(): void {
+ $context = $this->contextWithMenu(7, []);
+ $context->setTechnicalName('old');
+ $this->contextMapper->method('findById')->willReturn($context);
+ $this->contextMapper->method('update')->willReturnArgument(0);
+
+ $updated = $this->service->update(7, 'user-1', null, null, null, null, null, ' ');
+
+ $this->assertNull($updated->getTechnicalName());
+ }
+
+ public function testUpdateLeavesTheTechnicalNameAloneWhenNoneIsGiven(): void {
+ $context = $this->contextWithMenu(7, []);
+ $context->setTechnicalName('intake');
+ $this->contextMapper->method('findById')->willReturn($context);
+ $this->contextMapper->method('update')->willReturnArgument(0);
+ $this->contextMapper->expects($this->never())->method('isTechnicalNameTaken');
+
+ $updated = $this->service->update(7, 'user-1', 'Renamed', null, null, null);
+
+ $this->assertSame('intake', $updated->getTechnicalName());
+ }
+
+ public function testCreateStoresTheTechnicalName(): void {
+ $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context {
+ $context->setId(8);
+ return $context;
+ });
+ $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page): Page {
+ $page->setId(3);
+ return $page;
+ });
+ $this->menuItemMapper->method('findByContextId')->willReturn([]);
+
+ $created = $this->service->create('Intake portal', 'heart', '', [], 'user-1', 0, [], 'intake');
+
+ $this->assertSame('intake', $created->getTechnicalName());
+ }
+
+ public function testNavigationLinksOwnApplicationsByTechnicalNameAndOthersById(): void {
+ $own = $this->contextWithMenu(7, []);
+ $own->setTechnicalName('intake');
+ $shared = $this->contextWithMenu(8, []);
+ $shared->setOwnerId('someone-else');
+ $shared->setTechnicalName('intake');
+ $this->contextMapper->method('findForNavBar')->willReturn([$own, $shared]);
+ $linked = [];
+ $this->urlGenerator->method('linkToRoute')->willReturnCallback(static function (string $route, array $parameters) use (&$linked): string {
+ $linked[] = $parameters['contextId'];
+ return '/app/' . $parameters['contextId'];
+ });
+
+ $this->service->addToNavigation('user-1');
+
+ $this->assertSame(['intake', 8], $linked);
+ }
+
+ public function testImportGivesTheApplicationTheTechnicalNameOfTheScheme(): void {
+ $names = [];
+ $service = $this->getMockBuilder(ContextService::class)
+ ->setConstructorArgs($this->constructorArgs)
+ ->onlyMethods(['update', 'tableService', 'viewService', 'columnService'])
+ ->getMock();
+ $service->method('update')->willReturnCallback(function (int $contextId, string $userId, ?string $name, ?string $icon, ?string $description, ?array $nodes, ?array $menuItems = null, ?string $technicalName = null) use (&$names): Context {
+ $names[] = $technicalName;
+ return $this->contextWithMenu($contextId, []);
+ });
+ $this->contextMapper->method('findById')->willReturn($this->contextWithMenu(7, []));
+
+ $service->importScheme(7, new ContextScheme('Intake', 'star', '', [], null, ['addTables' => [], 'modifyTables' => []], null, [], 'intake'), 'user-1');
+
+ $this->assertSame(['intake'], $names);
+ }
+
public function testImportSetsTheNodesThenThePagesThenTheMenu(): void {
$calls = [];
$this->contextSchemeService->expects($this->once())->method('pagesFromGridViews')