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
81 changes: 81 additions & 0 deletions cypress/component/ImportContextSchemePreview.cy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import ImportContextSchemePreview from '../../src/modules/modals/ImportContextSchemePreview.vue'

const MENU_SWITCH = '[data-cy="importSchemeMenu"]'
const SKIPPED = '[data-cy="importSchemeMenuSkipped"]'

const schemeMenu = [
{ parent: null, title: 'Reports', displayTitle: 'Reports', icon: null, description: null, target: null },
{ parent: 0, title: null, displayTitle: 'Docs', icon: null, description: null, target: { url: 'https://example.org' } },
]

describe('Import application scheme preview', () => {
it('sends the menu of the scheme with the import', () => {
mountPreview({ from: [{ displayTitle: 'Old item' }], to: schemeMenu, skipped: [] })
.then(({ wrapper }) => {
cy.contains('Old item').should('exist')
cy.contains('Docs').should('exist')
cy.wrap(null).then(() => {
expect(lastPayload(wrapper).menu).to.deep.equal(schemeMenu)
})
})
})

it('leaves the menu out when the switch is turned off', () => {
mountPreview({ from: [], to: schemeMenu, skipped: [] })
.then(({ wrapper }) => {
cy.get(MENU_SWITCH).click({ force: true })
cy.wrap(null).then(() => {
expect(lastPayload(wrapper)).to.not.have.property('menu')
})
})
})

it('lists the items the import would skip', () => {
mountPreview({ from: [], to: [], skipped: [{ index: 0, title: 'Missing table', reason: 'Its table is not part of the application.' }] })

cy.get(SKIPPED).should('contain', 'Missing table')
})

it('has no menu section for a preview without a menu', () => {
mountPreview(undefined)
.then(({ wrapper }) => {
cy.get(MENU_SWITCH).should('not.exist')
cy.wrap(null).then(() => {
expect(lastPayload(wrapper)).to.not.have.property('menu')
})
})
})
})

/**
* @param {object|undefined} menu the menu part of the preview response
* @return {Cypress.Chainable} the mount result
*/
function mountPreview(menu) {
return cy.mount(ImportContextSchemePreview, {
props: {
previewData: {
name: { from: 'Intake', to: 'Intake' },
icon: { from: 'star', to: 'star' },
description: { from: '', to: '' },
nodes: { from: [], to: [] },
addTables: {},
modifyTables: {},
...(menu === undefined ? {} : { menu }),
},
},
})
}

/**
* @param {object} wrapper the mounted component
* @return {object} the last import body the preview emitted
*/
function lastPayload(wrapper) {
const emitted = wrapper.emitted('update')
return emitted[emitted.length - 1][0]
}
9 changes: 7 additions & 2 deletions lib/Controller/ContextController.php
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ public function exportScheme(int $contextId): DataResponse {
/**
* [api v2] Preview the changes that would be applied to a context scheme
*
* When the scheme carries a `menu`, the changes hold `menu` with the current menu under
* `from`, the items an import would save under `to` and the items it would skip, with
* the reason, under `skipped`.
*
* @param int $contextId ID of the context
* @psalm-param TablesContext $updateScheme New scheme of the context
*
Expand Down Expand Up @@ -355,6 +359,7 @@ public function previewSchemeChanges(int $contextId, array $updateScheme): DataR
* @param string $description Description of the context
* @param list<array{node_type: int, node_uuid: string, permissions: int}> $nodes Ordered meta data of the related nodes
* @psalm-param array{addTables: list<TablesTable>, modifyTables: list<TablesTable>} $tables Tables to be added or modified
* @param list<array<string, mixed>>|null $menu Menu of the scheme, replacing the menu of the context: pages by technical name, tables and views by uuid, the group of an item by its position in the list; items that cannot be used here are skipped. Left out, the menu stays as it is
*
* @return DataResponse<Http::STATUS_OK, array<string, mixed>, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND|Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST, array{message: string}, array{}>
*
Expand All @@ -365,10 +370,10 @@ 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): DataResponse {
public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, ?array $menu = null): DataResponse {
try {
$this->db->beginTransaction();
$context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId);
$context = $this->contextService->importScheme($contextId, $name, $iconName, $description, $nodes, $tables, $this->userId, $menu);
$this->db->commit();
return new DataResponse($context->jsonSerialize());
} catch (\InvalidArgumentException $e) {
Expand Down
6 changes: 6 additions & 0 deletions lib/Model/ContextScheme.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public function __construct(
protected ?array $nodes = [],
protected ?array $pages = [],
protected ?array $tables = [],
protected ?array $menu = [],
) {
}

Expand Down Expand Up @@ -45,6 +46,10 @@ public function getTables(): ?array {
return $this->tables;
}

public function getMenu(): ?array {
return $this->menu;
}

public function jsonSerialize(): mixed {
return [
'name' => $this->name,
Expand All @@ -53,6 +58,7 @@ public function jsonSerialize(): mixed {
'nodes' => $this->nodes,
'pages' => $this->pages,
'tables' => $this->tables,
'menu' => $this->menu,
];
}
}
76 changes: 74 additions & 2 deletions lib/Service/ContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public function __construct(
private WidgetService $widgetService,
private GridWidgetTypes $gridWidgetTypes,
private MenuItemMapper $menuItemMapper,
private MenuService $menuService,
) {
}

Expand Down Expand Up @@ -655,15 +656,20 @@ private function importablePages(array $pages): array {
}

/**
* Imports an application of a user's data that holds the given table. Its menu keeps
* the links, the items for its pages and the items for the table, found by the uuid
* it was exported with.
*
* @param Table $table
* @param array $context
* @param int $tableOldId
* @param string|null $tableOldUuid
*
* @return Context|null
*
* @throws \Exception
*/
public function importContext(Table $table, array $context, int $tableOldId): ?Context {
public function importContext(Table $table, array $context, int $tableOldId, ?string $tableOldUuid = null): ?Context {
// checking if any node in $context['nodes'] matches the old table id
$hasMatchingNode = false;
if (!empty($context['nodes'])) {
Expand Down Expand Up @@ -758,9 +764,48 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C
}
}

if (is_array($context['menu'] ?? null)) {
$tableRelation = reset($sourceRelMap);
$this->logSkippedMenuItems($this->menuService->import(
$this->contextMapper->findById($newContext->getId()),
$context['menu'],
static fn (string $kind, string $uuid): ?int => $kind === 'table' && $uuid === $tableOldUuid && $tableRelation !== false ? $tableRelation->getId() : null,
));
}

return $newContext;
}

/**
* @param list<array{index: int, title: ?string, reason: string}> $skipped
*/
private function logSkippedMenuItems(array $skipped): void {
foreach ($skipped as $item) {
$this->logger->warning('Skipping imported menu item ' . $item['index'] . ': ' . $item['reason']);
}
}

/**
* The node relation of an application for an exported table or view, found by uuid.
*/
private function nodeRelIdByUuid(Context $context, string $kind, string $uuid): ?int {
try {
$node = $kind === 'table' ? $this->tableMapper->findByUuid($uuid) : $this->viewMapper->findByUuid($uuid);
} catch (DoesNotExistException) {
return null;
}
if ($node === null) {
return null;
}
$nodeType = $kind === 'table' ? Application::NODE_TYPE_TABLE : Application::NODE_TYPE_VIEW;
foreach ($context->getNodes() ?? [] as $relId => $relation) {
if ($relation['node_type'] === $nodeType && $relation['node_id'] === $node->getId()) {
return (int)$relId;
}
}
return null;
}

/**
* The technical names of the pages of an imported application, by page key. A valid
* exported name is kept unless an earlier page of the import uses it or it has the
Expand Down Expand Up @@ -926,6 +971,7 @@ public function getScheme(int $contextId, string $userId): ContextScheme {
array_values($nodes),
$this->getPagesWithWidgets($context),
array_values($tables),
$this->menuService->export($context),
);
}

Expand Down Expand Up @@ -995,6 +1041,24 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string
}
}

if (is_array($updateScheme['menu'] ?? null)) {
$schemeUuids = [];
foreach ($changes['nodes']['to'] ?? [] as $node) {
$kind = ($node['node_type'] ?? null) === Application::NODE_TYPE_VIEW ? 'view' : 'table';
$schemeUuids[$kind][(string)($node['node_uuid'] ?? '')] = true;
}
['items' => $items, 'skipped' => $skipped] = $this->menuService->readExported(
$context,
$updateScheme['menu'],
static fn (string $kind, string $uuid): ?int => isset($schemeUuids[$kind][$uuid]) ? 0 : null,
);
$changes['menu'] = [
'from' => $currentScheme->getMenu(),
'to' => array_column($items, 'entry'),
'skipped' => $skipped,
];
}

return $changes;
}

Expand All @@ -1019,7 +1083,7 @@ public function compareSchemeChanges(int $contextId, array $updateScheme, string
* @throws NotFoundExceptionInterface
* @throws PermissionError
*/
public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId): Context {
public function importScheme(int $contextId, string $name, string $iconName, string $description, array $nodes, array $tables, string $userId, ?array $menu = null): Context {
// Validate the structure of the columns and views arrays
if (!isset($tables['addTables']) || !is_array($tables['addTables'])
|| !isset($tables['modifyTables']) || !is_array($tables['modifyTables'])) {
Expand Down Expand Up @@ -1080,6 +1144,14 @@ public function importScheme(int $contextId, string $name, string $iconName, str
}

$context = $this->update($contextId, $userId, $name, $iconName, $description, $resolvedNodes);
if ($menu !== null) {
$withNodes = $this->contextMapper->findById($contextId, $userId);
$this->logSkippedMenuItems($this->menuService->import(
$withNodes,
$menu,
fn (string $kind, string $uuid): ?int => $this->nodeRelIdByUuid($withNodes, $kind, $uuid),
));
}

return $context;
}
Expand Down
Loading
Loading