From bc984d8ef5ff31f4f4a0d9f5358c503d70093f28 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 16 Oct 2026 09:23:59 +0200 Subject: [PATCH] feat(pages): store grid pages and their widgets relationally Adds the data model of nextcloud/tables#3100: pages get a title, emoji and description and the grid page type; widgets, their settings and the node a data widget shows live in tables_contexts_widget, tables_contexts_widget_setting and tables_contexts_widget_source. Deleting an application or page deletes its widgets; removing a node deletes only the widget sources. Scheme and user migration export carry the widgets, import remaps their node. Assisted-by: ClaudeCode:claude-opus-5-5 --- appinfo/info.xml | 2 +- lib/Db/ContextMapper.php | 7 +- lib/Db/Page.php | 13 ++ lib/Db/Widget.php | 62 +++++++ lib/Db/WidgetMapper.php | 78 ++++++++ lib/Db/WidgetSetting.php | 33 ++++ lib/Db/WidgetSettingMapper.php | 88 +++++++++ lib/Db/WidgetSource.php | 31 ++++ lib/Db/WidgetSourceMapper.php | 99 +++++++++++ .../Version2400Date20261016000001.php | 98 ++++++++++ lib/Service/ContextService.php | 57 +++++- lib/Service/WidgetService.php | 124 +++++++++++++ lib/UserMigration/TablesMigrator.php | 3 + tests/unit/Db/ContextMapperTest.php | 122 +++++++++++++ .../Service/ContextPagesRoundTripTest.php | 168 ++++++++++++++++++ tests/unit/Service/ContextServiceTest.php | 108 ++++++++++- tests/unit/Service/WidgetServiceTest.php | 139 +++++++++++++++ tests/unit/TablesMigratorTest.php | 29 +++ 18 files changed, 1255 insertions(+), 6 deletions(-) create mode 100644 lib/Db/Widget.php create mode 100644 lib/Db/WidgetMapper.php create mode 100644 lib/Db/WidgetSetting.php create mode 100644 lib/Db/WidgetSettingMapper.php create mode 100644 lib/Db/WidgetSource.php create mode 100644 lib/Db/WidgetSourceMapper.php create mode 100644 lib/Migration/Version2400Date20261016000001.php create mode 100644 lib/Service/WidgetService.php create mode 100644 tests/unit/Db/ContextMapperTest.php create mode 100644 tests/unit/Service/ContextPagesRoundTripTest.php create mode 100644 tests/unit/Service/WidgetServiceTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 8545043c14..61b25aa33b 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.0 + 2.4.0-dev.5 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 0da199335e..71e13a5ff4 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -37,7 +37,7 @@ protected function getFindContextBaseQuery(?string $userId): IQueryBuilder { $qb->select( 'c.*', 'r.id as node_rel_id', 'r.node_id', 'r.node_type', 'r.permissions', - 'p.id as page_id', 'p.page_type', + 'p.id as page_id', 'p.page_type', 'p.title as page_title', 'p.emoji as page_emoji', 'p.description as page_description', 'pc.id as content_id', 'pc.order', 'n.display_mode as display_mode_default', 's.id as share_id', 's.receiver', 's.receiver_type' @@ -131,7 +131,10 @@ protected function formatResultRows(array $rows, ?string $userId) { $carry[$item['page_id']] ??= ['content' => []]; $carry[$item['page_id']]['id'] = (int)$item['page_id']; $carry[$item['page_id']]['page_type'] = $item['page_type']; - if ($item['node_rel_id'] !== null) { + $carry[$item['page_id']]['title'] = $item['page_title']; + $carry[$item['page_id']]['emoji'] = $item['page_emoji']; + $carry[$item['page_id']]['description'] = $item['page_description']; + if ($item['node_rel_id'] !== null && $item['content_id'] !== null) { $carry[$item['page_id']]['content'][$item['content_id']] = [ 'order' => (int)$item['order'], 'node_rel_id' => (int)$item['node_rel_id'] diff --git a/lib/Db/Page.php b/lib/Db/Page.php index 5ceeb1bd00..5e18089da3 100644 --- a/lib/Db/Page.php +++ b/lib/Db/Page.php @@ -15,12 +15,22 @@ * @method setContextId(int $value): void * @method getPageType(): string * @method setPageType(string $value): void + * @method getTitle(): ?string + * @method setTitle(?string $value): void + * @method getEmoji(): ?string + * @method setEmoji(?string $value): void + * @method getDescription(): ?string + * @method setDescription(?string $value): void */ class Page extends Entity implements \JsonSerializable { public const TYPE_STARTPAGE = 'startpage'; + public const TYPE_GRID = 'grid'; protected ?int $contextId = null; protected ?string $pageType = null; + protected ?string $title = null; + protected ?string $emoji = null; + protected ?string $description = null; public function __construct() { $this->addType('id', 'integer'); @@ -31,6 +41,9 @@ public function jsonSerialize(): array { 'id' => $this->getId(), 'contextId' => $this->getContextId(), 'pageType' => $this->getPageType(), + 'title' => $this->getTitle(), + 'emoji' => $this->getEmoji(), + 'description' => $this->getDescription(), ]; } } diff --git a/lib/Db/Widget.php b/lib/Db/Widget.php new file mode 100644 index 0000000000..ee657cec71 --- /dev/null +++ b/lib/Db/Widget.php @@ -0,0 +1,62 @@ +addType('id', 'integer'); + $this->addType('page_id', 'integer'); + $this->addType('grid_x', 'integer'); + $this->addType('grid_y', 'integer'); + $this->addType('grid_width', 'integer'); + $this->addType('grid_height', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->getId(), + 'pageId' => $this->getPageId(), + 'type' => $this->getType(), + 'title' => $this->getTitle(), + 'gridX' => $this->getGridX(), + 'gridY' => $this->getGridY(), + 'gridWidth' => $this->getGridWidth(), + 'gridHeight' => $this->getGridHeight(), + ]; + } +} diff --git a/lib/Db/WidgetMapper.php b/lib/Db/WidgetMapper.php new file mode 100644 index 0000000000..9843b30c77 --- /dev/null +++ b/lib/Db/WidgetMapper.php @@ -0,0 +1,78 @@ + */ +class WidgetMapper extends QBMapper { + protected string $table = 'tables_contexts_widget'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, Widget::class); + } + + /** + * @param int[] $pageIds + * @return Widget[] ordered by page and grid position + * @throws Exception + */ + public function findByPageIds(array $pageIds): array { + if ($pageIds === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->in('page_id', $qb->createNamedParameter($pageIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->orderBy('page_id', 'ASC') + ->addOrderBy('grid_y', 'ASC') + ->addOrderBy('grid_x', 'ASC') + ->addOrderBy('id', 'ASC'); + + return $this->findEntities($qb); + } + + /** + * @return int[] + * @throws Exception + */ + public function findIdsByPageId(int $pageId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('id') + ->from($this->table) + ->where($qb->expr()->eq('page_id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT))); + + $result = $qb->executeQuery(); + $ids = []; + while ($row = $result->fetchAssociative()) { + $ids[] = (int)$row['id']; + } + $result->closeCursor(); + return $ids; + } + + /** + * @param int[] $widgetIds + * @throws Exception + */ + public function deleteByIds(array $widgetIds): void { + if ($widgetIds === []) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->in('id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + } +} diff --git a/lib/Db/WidgetSetting.php b/lib/Db/WidgetSetting.php new file mode 100644 index 0000000000..983188dec9 --- /dev/null +++ b/lib/Db/WidgetSetting.php @@ -0,0 +1,33 @@ +addType('id', 'integer'); + $this->addType('widget_id', 'integer'); + } +} diff --git a/lib/Db/WidgetSettingMapper.php b/lib/Db/WidgetSettingMapper.php new file mode 100644 index 0000000000..7b2c834e1a --- /dev/null +++ b/lib/Db/WidgetSettingMapper.php @@ -0,0 +1,88 @@ + value maps. Values are stored JSON + * encoded, one row per setting. + * + * @psalm-type TablesWidgetSettingValue = string|int|float|bool|null + * @template-extends QBMapper + */ +class WidgetSettingMapper extends QBMapper { + protected string $table = 'tables_contexts_widget_setting'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, WidgetSetting::class); + } + + /** + * @param int[] $widgetIds + * @return array> settings by widget id + * @throws Exception + */ + public function findSettingsByWidgetIds(array $widgetIds): array { + if ($widgetIds === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select('widget_id', 'name', 'value') + ->from($this->table) + ->where($qb->expr()->in('widget_id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->orderBy('id', 'ASC'); + + $result = $qb->executeQuery(); + $settings = []; + while ($row = $result->fetchAssociative()) { + $settings[(int)$row['widget_id']][(string)$row['name']] = $row['value'] === null + ? null + : json_decode((string)$row['value'], true); + } + $result->closeCursor(); + return $settings; + } + + /** + * Replaces all settings of a widget. + * + * @param array $settings + * @throws Exception + * @throws \JsonException + */ + public function replaceSettings(int $widgetId, array $settings): void { + $this->deleteByWidgetIds([$widgetId]); + foreach ($settings as $name => $value) { + $setting = new WidgetSetting(); + $setting->setWidgetId($widgetId); + $setting->setName((string)$name); + $setting->setValue($value === null ? null : json_encode($value, JSON_THROW_ON_ERROR)); + $this->insert($setting); + } + } + + /** + * @param int[] $widgetIds + * @throws Exception + */ + public function deleteByWidgetIds(array $widgetIds): void { + if ($widgetIds === []) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->in('widget_id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + } +} diff --git a/lib/Db/WidgetSource.php b/lib/Db/WidgetSource.php new file mode 100644 index 0000000000..64fc9a49f2 --- /dev/null +++ b/lib/Db/WidgetSource.php @@ -0,0 +1,31 @@ +addType('widget_id', 'integer'); + $this->addType('node_rel_id', 'integer'); + } +} diff --git a/lib/Db/WidgetSourceMapper.php b/lib/Db/WidgetSourceMapper.php new file mode 100644 index 0000000000..9041e7d849 --- /dev/null +++ b/lib/Db/WidgetSourceMapper.php @@ -0,0 +1,99 @@ + + */ +class WidgetSourceMapper extends QBMapper { + protected string $table = 'tables_contexts_widget_source'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, $this->table, WidgetSource::class); + } + + /** + * @param int[] $widgetIds + * @return array node relation ids by widget id + * @throws Exception + */ + public function findNodeRelIdsByWidgetIds(array $widgetIds): array { + if ($widgetIds === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select('widget_id', 'node_rel_id') + ->from($this->table) + ->where($qb->expr()->in('widget_id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY))); + + $result = $qb->executeQuery(); + $sources = []; + while ($row = $result->fetchAssociative()) { + $sources[(int)$row['widget_id']] = (int)$row['node_rel_id']; + } + $result->closeCursor(); + return $sources; + } + + /** + * Points a widget at a node relation, or removes its source when null. + * + * @throws Exception + */ + public function setSource(int $widgetId, ?int $nodeRelId): void { + $this->deleteByWidgetIds([$widgetId]); + if ($nodeRelId === null) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->insert($this->table) + ->values([ + 'widget_id' => $qb->createNamedParameter($widgetId, IQueryBuilder::PARAM_INT), + 'node_rel_id' => $qb->createNamedParameter($nodeRelId, IQueryBuilder::PARAM_INT), + ]) + ->executeStatement(); + } + + /** + * @param int[] $widgetIds + * @throws Exception + */ + public function deleteByWidgetIds(array $widgetIds): void { + if ($widgetIds === []) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->in('widget_id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + } + + /** + * @param int[] $nodeRelIds + * @throws Exception + */ + public function deleteByNodeRelIds(array $nodeRelIds): void { + if ($nodeRelIds === []) { + return; + } + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->table) + ->where($qb->expr()->in('node_rel_id', $qb->createNamedParameter($nodeRelIds, IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + } +} diff --git a/lib/Migration/Version2400Date20261016000001.php b/lib/Migration/Version2400Date20261016000001.php new file mode 100644 index 0000000000..f63f37918b --- /dev/null +++ b/lib/Migration/Version2400Date20261016000001.php @@ -0,0 +1,98 @@ +extendPageTable($schema); + $this->haveWidgetTable($schema); + $this->haveWidgetSettingTable($schema); + $this->haveWidgetSourceTable($schema); + + return $schema; + } + + private function extendPageTable(ISchemaWrapper $schema): void { + if (!$schema->hasTable(self::PREFIX . 'page')) { + return; + } + $table = $schema->getTable(self::PREFIX . 'page'); + if (!$table->hasColumn('title')) { + $table->addColumn('title', Types::STRING, ['notnull' => false, 'length' => 200]); + } + if (!$table->hasColumn('emoji')) { + $table->addColumn('emoji', Types::STRING, ['notnull' => false, 'length' => 64]); + } + if (!$table->hasColumn('description')) { + $table->addColumn('description', Types::TEXT, ['notnull' => false]); + } + if (!$table->hasIndex('tables_ctx_page_ctx_idx')) { + $table->addIndex(['context_id'], 'tables_ctx_page_ctx_idx'); + } + } + + private function haveWidgetTable(ISchemaWrapper $schema): void { + if ($schema->hasTable(self::PREFIX . 'widget')) { + return; + } + $table = $schema->createTable(self::PREFIX . 'widget'); + $table->addColumn('id', Types::INTEGER, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('page_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('type', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('title', Types::STRING, ['notnull' => false, 'length' => 200]); + $table->addColumn('grid_x', Types::SMALLINT, ['notnull' => true, 'default' => 0]); + $table->addColumn('grid_y', Types::INTEGER, ['notnull' => true, 'default' => 0]); + $table->addColumn('grid_width', Types::SMALLINT, ['notnull' => true, 'default' => 1]); + $table->addColumn('grid_height', Types::SMALLINT, ['notnull' => true, 'default' => 1]); + $table->setPrimaryKey(['id']); + $table->addIndex(['page_id'], 'tables_ctx_widget_page_idx'); + } + + private function haveWidgetSettingTable(ISchemaWrapper $schema): void { + if ($schema->hasTable(self::PREFIX . 'widget_setting')) { + return; + } + $table = $schema->createTable(self::PREFIX . 'widget_setting'); + $table->addColumn('id', Types::INTEGER, ['autoincrement' => true, 'notnull' => true]); + $table->addColumn('widget_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 64]); + $table->addColumn('value', Types::TEXT, ['notnull' => false]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['widget_id', 'name'], 'tables_ctx_wsetting_uq'); + } + + private function haveWidgetSourceTable(ISchemaWrapper $schema): void { + if ($schema->hasTable(self::PREFIX . 'widget_source')) { + return; + } + $table = $schema->createTable(self::PREFIX . 'widget_source'); + $table->addColumn('widget_id', Types::INTEGER, ['notnull' => true]); + $table->addColumn('node_rel_id', Types::INTEGER, ['notnull' => true]); + $table->setPrimaryKey(['widget_id']); + $table->addIndex(['node_rel_id'], 'tables_ctx_wsource_node_idx'); + } +} diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 468d3de83e..c05283ce6f 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -62,6 +62,7 @@ public function __construct( protected IURLGenerator $urlGenerator, private TableMapper $tableMapper, private ViewMapper $viewMapper, + private WidgetService $widgetService, ) { } @@ -319,6 +320,7 @@ public function delete(int $contextId, string $userId): Context { $this->shareService->deleteAllForContext($context); $this->contextNodeRelMapper->deleteAllByContextId($context->getId()); $pageIds = $this->pageMapper->getPageIdsForContext($context->getId()); + $this->widgetService->deleteByPageIds($pageIds); foreach ($pageIds as $pageId) { $this->pageContentMapper->deleteByPageId($pageId); $this->pageMapper->deleteByPageId($pageId); @@ -332,6 +334,7 @@ public function deleteNodeRel(int $nodeId, int $nodeType): void { try { $nodeRelIds = $this->contextNodeRelMapper->getRelIdsForNode($nodeId, $nodeType); $this->atomic(function () use ($nodeRelIds): void { + $this->widgetService->deleteSourcesByNodeRelIds($nodeRelIds); $this->pageContentMapper->deleteByNodeRelIds($nodeRelIds); $this->contextNodeRelMapper->deleteByNodeRelIds($nodeRelIds); }, $this->dbc); @@ -432,6 +435,7 @@ public function addNodeToContextAndStartpage(int $contextId, int $nodeId, int $n */ public function removeNodeFromContextAndPages(int $nodeRelationId): array { $nodeRelation = $this->removeNodeFromContextById($nodeRelationId); + $this->widgetService->deleteSourcesByNodeRelIds([$nodeRelationId]); $contents = $this->removeNodeRelFromAllPages($nodeRelation); return [$nodeRelation, $contents]; } @@ -635,6 +639,9 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C $page = new Page(); $page->setContextId($newContext->getId()); $page->setPageType($pageArr['page_type'] ?? $pageArr['pageType'] ?? ''); + $page->setTitle($pageArr['title'] ?? null); + $page->setEmoji($pageArr['emoji'] ?? null); + $page->setDescription($pageArr['description'] ?? null); $page = $this->pageMapper->insert($page); $pageMap[$pageArr['id'] ?? null] = $page; @@ -650,12 +657,60 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C $this->pageContentMapper->insert($pageContent); } } + + foreach ($pageArr['widgets'] ?? [] as $widgetArr) { + $this->importWidget($page->getId(), $widgetArr, $nodeRelMap); + } } } return $newContext; } + /** + * The pages of an application with the widgets of each page under `widgets`. + * + * @throws Exception + */ + public function getPagesWithWidgets(Context $context): array { + $pages = $context->getPages() ?? []; + $widgets = $this->widgetService->findByPageIds(array_keys($pages)); + foreach ($pages as $pageId => $page) { + $pages[$pageId]['widgets'] = $widgets[$pageId] ?? []; + } + return $pages; + } + + /** + * Inserts an exported widget on a page. Its source is remapped to the imported node + * relation, and dropped when that node was not imported. + * + * @param array $nodeRelMap imported node relations by their exported id + * @throws Exception + * @throws \JsonException + */ + private function importWidget(int $pageId, mixed $widgetArr, array $nodeRelMap): void { + if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { + return; + } + $oldNodeRelId = $widgetArr['source']['nodeRelId'] ?? null; + $newNodeRel = $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; + $settings = is_array($widgetArr['settings'] ?? null) ? $widgetArr['settings'] : []; + $this->widgetService->insert( + $pageId, + $widgetArr['type'], + is_string($widgetArr['title'] ?? null) ? $widgetArr['title'] : null, + [ + 'gridX' => (int)($widgetArr['gridX'] ?? 0), + 'gridY' => (int)($widgetArr['gridY'] ?? 0), + 'gridWidth' => max(1, (int)($widgetArr['gridWidth'] ?? 1)), + 'gridHeight' => max(1, (int)($widgetArr['gridHeight'] ?? 1)), + ], + $settings, + $newNodeRel?->getId(), + ); + } + /** * @param int $contextId * @@ -705,7 +760,7 @@ public function getScheme(int $contextId, string $userId): ContextScheme { $context->getIcon(), $context->getDescription(), array_values($nodes), - $context->getPages(), + $this->getPagesWithWidgets($context), array_values($tables), ); } diff --git a/lib/Service/WidgetService.php b/lib/Service/WidgetService.php new file mode 100644 index 0000000000..8279d6e26b --- /dev/null +++ b/lib/Service/WidgetService.php @@ -0,0 +1,124 @@ +, source: ?array{nodeRelId: int}} + */ +class WidgetService { + public function __construct( + private readonly WidgetMapper $widgetMapper, + private readonly WidgetSettingMapper $widgetSettingMapper, + private readonly WidgetSourceMapper $widgetSourceMapper, + ) { + } + + /** + * @param int[] $pageIds + * @return array> widgets by page id, in grid order + * @throws Exception + */ + public function findByPageIds(array $pageIds): array { + $widgets = $this->widgetMapper->findByPageIds($pageIds); + $widgetIds = array_map(static fn (Widget $widget): int => $widget->getId(), $widgets); + $settings = $this->widgetSettingMapper->findSettingsByWidgetIds($widgetIds); + $sources = $this->widgetSourceMapper->findNodeRelIdsByWidgetIds($widgetIds); + + $byPage = []; + foreach ($widgets as $widget) { + $id = $widget->getId(); + $byPage[$widget->getPageId()][] = [ + 'id' => $id, + 'type' => $widget->getType(), + 'title' => $widget->getTitle(), + 'gridX' => $widget->getGridX(), + 'gridY' => $widget->getGridY(), + 'gridWidth' => $widget->getGridWidth(), + 'gridHeight' => $widget->getGridHeight(), + 'settings' => $settings[$id] ?? [], + 'source' => isset($sources[$id]) ? ['nodeRelId' => $sources[$id]] : null, + ]; + } + return $byPage; + } + + /** + * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position + * @param array $settings + * @throws Exception + * @throws \JsonException + */ + public function insert(int $pageId, string $type, ?string $title, array $position, array $settings, ?int $nodeRelId): Widget { + $widget = new Widget(); + $widget->setPageId($pageId); + $this->applyFields($widget, $type, $title, $position); + $widget = $this->widgetMapper->insert($widget); + + $this->widgetSettingMapper->replaceSettings($widget->getId(), $settings); + $this->widgetSourceMapper->setSource($widget->getId(), $nodeRelId); + return $widget; + } + + /** + * Deletes the widgets of the given pages with their settings and sources. + * + * @param int[] $pageIds + * @throws Exception + */ + public function deleteByPageIds(array $pageIds): void { + $widgetIds = []; + foreach ($pageIds as $pageId) { + $widgetIds = array_merge($widgetIds, $this->widgetMapper->findIdsByPageId($pageId)); + } + $this->deleteByIds($widgetIds); + } + + /** + * @param int[] $widgetIds + * @throws Exception + */ + public function deleteByIds(array $widgetIds): void { + $this->widgetSettingMapper->deleteByWidgetIds($widgetIds); + $this->widgetSourceMapper->deleteByWidgetIds($widgetIds); + $this->widgetMapper->deleteByIds($widgetIds); + } + + /** + * Unlinks every widget that shows one of the given node relations. The widgets stay. + * + * @param int[] $nodeRelIds + * @throws Exception + */ + public function deleteSourcesByNodeRelIds(array $nodeRelIds): void { + $this->widgetSourceMapper->deleteByNodeRelIds($nodeRelIds); + } + + /** + * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position + */ + private function applyFields(Widget $widget, string $type, ?string $title, array $position): void { + $widget->setType($type); + $widget->setTitle($title); + $widget->setGridX($position['gridX']); + $widget->setGridY($position['gridY']); + $widget->setGridWidth($position['gridWidth']); + $widget->setGridHeight($position['gridHeight']); + } +} diff --git a/lib/UserMigration/TablesMigrator.php b/lib/UserMigration/TablesMigrator.php index ec4d528307..29edfa77b2 100644 --- a/lib/UserMigration/TablesMigrator.php +++ b/lib/UserMigration/TablesMigrator.php @@ -122,6 +122,9 @@ public function export( $exportDestination->addFileContents(self::FILE_VIEWS, json_encode($views)); $contexts = $this->contextMapper->findAll($uid); + foreach ($contexts as $context) { + $context->setPages($this->contextService->getPagesWithWidgets($context)); + } $exportDestination->addFileContents(self::FILE_CONTEXTS, json_encode($contexts)); $contextIds = array_map(fn ($c) => $c->getId(), $contexts); diff --git a/tests/unit/Db/ContextMapperTest.php b/tests/unit/Db/ContextMapperTest.php new file mode 100644 index 0000000000..013a3ead18 --- /dev/null +++ b/tests/unit/Db/ContextMapperTest.php @@ -0,0 +1,122 @@ +createMock(UserHelper::class); + $userHelper->method('getGroupIdsForUser')->willReturn([]); + $this->mapper = new ContextMapper($this->connectionAdapter, $userHelper, $this->createMock(GroupHelper::class)); + $this->nodeRelMapper = new ContextNodeRelationMapper($this->connectionAdapter); + $this->pageMapper = new PageMapper($this->connectionAdapter); + $this->pageContentMapper = new PageContentMapper($this->connectionAdapter); + } + + protected function tearDown(): void { + foreach ($this->contexts as $context) { + foreach ($this->pageMapper->getPageIdsForContext($context->getId()) as $pageId) { + $this->pageContentMapper->deleteByPageId($pageId); + $this->pageMapper->deleteByPageId($pageId); + } + $this->nodeRelMapper->deleteAllByContextId($context->getId()); + $this->mapper->delete($context); + } + parent::tearDown(); + } + + public function testGridPageHasItsFieldsAndNoStartPageContent(): void { + $context = $this->createContext(); + $first = $this->addNode($context, 11); + $second = $this->addNode($context, 12); + $startPage = $this->addPage($context, Page::TYPE_STARTPAGE, null, null, null); + $firstContent = $this->addContent($startPage, $first, 10); + $secondContent = $this->addContent($startPage, $second, 20); + $gridPage = $this->addPage($context, Page::TYPE_GRID, 'Overview', '📊', 'Open work at a glance'); + + $pages = $this->mapper->findById($context->getId(), self::OWNER)->getPages(); + + $this->assertEqualsCanonicalizing([$startPage->getId(), $gridPage->getId()], array_keys($pages)); + $this->assertSame([ + 'content' => [], + 'id' => $gridPage->getId(), + 'page_type' => Page::TYPE_GRID, + 'title' => 'Overview', + 'emoji' => '📊', + 'description' => 'Open work at a glance', + ], $pages[$gridPage->getId()]); + $this->assertNull($pages[$startPage->getId()]['title']); + $this->assertSame([$firstContent->getId(), $secondContent->getId()], array_keys($pages[$startPage->getId()]['content'])); + $this->assertSame([$first->getId(), $second->getId()], array_keys($this->mapper->findById($context->getId(), self::OWNER)->getNodes())); + } + + private function createContext(): Context { + $context = new Context(); + $context->setName('Mapper test'); + $context->setIcon('tables'); + $context->setDescription(''); + $context->setOwnerId(self::OWNER); + $context->setOwnerType(Application::OWNER_TYPE_USER); + $this->contexts[] = $context = $this->mapper->insert($context); + return $context; + } + + private function addNode(Context $context, int $nodeId): ContextNodeRelation { + $relation = new ContextNodeRelation(); + $relation->setContextId($context->getId()); + $relation->setNodeId($nodeId); + $relation->setNodeType(Application::NODE_TYPE_TABLE); + $relation->setPermissions(Application::PERMISSION_READ); + return $this->nodeRelMapper->insert($relation); + } + + private function addPage(Context $context, string $type, ?string $title, ?string $emoji, ?string $description): Page { + $page = new Page(); + $page->setContextId($context->getId()); + $page->setPageType($type); + $page->setTitle($title); + $page->setEmoji($emoji); + $page->setDescription($description); + return $this->pageMapper->insert($page); + } + + private function addContent(Page $page, ContextNodeRelation $relation, int $order): PageContent { + $content = new PageContent(); + $content->setPageId($page->getId()); + $content->setNodeRelId($relation->getId()); + $content->setOrder($order); + return $this->pageContentMapper->insert($content); + } +} diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php new file mode 100644 index 0000000000..a7a197efbc --- /dev/null +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -0,0 +1,168 @@ +createMock(UserHelper::class); + $userHelper->method('getGroupIdsForUser')->willReturn([]); + $this->contextMapper = new ContextMapper($this->connectionAdapter, $userHelper, $this->createMock(GroupHelper::class)); + $this->nodeRelMapper = new ContextNodeRelationMapper($this->connectionAdapter); + $this->pageMapper = new PageMapper($this->connectionAdapter); + $this->widgetService = new WidgetService( + new WidgetMapper($this->connectionAdapter), + new WidgetSettingMapper($this->connectionAdapter), + new WidgetSourceMapper($this->connectionAdapter), + ); + $this->service = new ContextService( + $this->contextMapper, + $this->nodeRelMapper, + $this->pageMapper, + new PageContentMapper($this->connectionAdapter), + $this->createMock(LoggerInterface::class), + $this->createMock(PermissionsService::class), + $this->createMock(IUserManager::class), + $this->createMock(IEventDispatcher::class), + $this->connectionAdapter, + $this->createMock(ShareService::class), + false, + $this->createMock(INavigationManager::class), + $this->createMock(IURLGenerator::class), + $this->createMock(TableMapper::class), + $this->createMock(ViewMapper::class), + $this->widgetService, + ); + } + + protected function tearDown(): void { + foreach ($this->contextIds as $contextId) { + try { + $this->service->delete($contextId, self::OWNER); + } catch (\Throwable) { + } + } + parent::tearDown(); + } + + public function testExportedWidgetsAreImportedWithTheirNode(): void { + [$context, $relation, $gridPageId] = $this->createContextWithGridPage(); + $table = new Table(); + $table->id = 4242; + $table->setOwnership(self::OWNER); + + $exported = json_decode(json_encode(array_merge( + $this->contextMapper->findById($context->getId(), self::OWNER)->jsonSerialize(), + ['pages' => $this->service->getPagesWithWidgets($this->contextMapper->findById($context->getId(), self::OWNER))], + )), true); + $exported['nodes'][$relation->getId()]['node_id'] = 4242; + + $imported = $this->service->importContext($table, $exported, 4242); + $this->assertNotNull($imported); + $this->contextIds[] = $imported->getId(); + + $importedContext = $this->contextMapper->findById($imported->getId(), self::OWNER); + $importedRelationId = array_key_first($importedContext->getNodes()); + $importedPages = $this->service->getPagesWithWidgets($importedContext); + $gridPages = array_values(array_filter($importedPages, static fn (array $page): bool => $page['page_type'] === Page::TYPE_GRID)); + $this->assertCount(1, $gridPages); + $this->assertSame(['Overview', '📊', 'At a glance'], [$gridPages[0]['title'], $gridPages[0]['emoji'], $gridPages[0]['description']]); + + $original = $this->service->getPagesWithWidgets($this->contextMapper->findById($context->getId(), self::OWNER))[$gridPageId]['widgets']; + $withoutIds = static fn (array $widgets): array => array_map(static function (array $widget): array { + unset($widget['id']); + return $widget; + }, $widgets); + $expected = $withoutIds($original); + $expected[1]['source'] = ['nodeRelId' => $importedRelationId]; + $this->assertNotSame($relation->getId(), $importedRelationId); + $this->assertSame($expected, $withoutIds($gridPages[0]['widgets'])); + } + + public function testRemovingTheNodeKeepsTheWidgetAndDeletingTheContextRemovesIt(): void { + [$context, $relation, $gridPageId] = $this->createContextWithGridPage(); + + $this->service->removeNodeFromContextAndPages($relation->getId()); + + $widgets = $this->widgetService->findByPageIds([$gridPageId])[$gridPageId]; + $this->assertCount(2, $widgets); + $this->assertNull($widgets[1]['source']); + $this->assertSame(['pageSize' => 25], $widgets[1]['settings']); + + $this->service->delete($context->getId(), self::OWNER); + + $this->assertSame([], $this->widgetService->findByPageIds([$gridPageId])); + $this->assertSame([], $this->pageMapper->getPageIdsForContext($context->getId())); + } + + /** + * @return array{0: Context, 1: ContextNodeRelation, 2: int} + */ + private function createContextWithGridPage(): array { + $context = $this->service->create('Projects', 'tables', '', [], self::OWNER, Application::OWNER_TYPE_USER); + $this->contextIds[] = $context->getId(); + $relation = $this->service->addNodeToContext($context, 4141, Application::NODE_TYPE_TABLE, Application::PERMISSION_READ); + $this->service->addNodeRelToPage($relation); + + $page = new Page(); + $page->setContextId($context->getId()); + $page->setPageType(Page::TYPE_GRID); + $page->setTitle('Overview'); + $page->setEmoji('📊'); + $page->setDescription('At a glance'); + $page = $this->pageMapper->insert($page); + + $this->widgetService->insert($page->getId(), 'header', 'Welcome', ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], ['text' => 'Projects', 'showTitle' => false], null); + $this->widgetService->insert($page->getId(), 'data', null, ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], ['pageSize' => 25], $relation->getId()); + + return [$context, $relation, $page->getId()]; + } +} diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 266827b7cd..6443fe3e05 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -11,11 +11,13 @@ use OCA\Tables\Db\Context; use OCA\Tables\Db\ContextMapper; +use OCA\Tables\Db\ContextNodeRelation; use OCA\Tables\Db\ContextNodeRelationMapper; use OCA\Tables\Db\Page; use OCA\Tables\Db\PageContent; use OCA\Tables\Db\PageContentMapper; use OCA\Tables\Db\PageMapper; +use OCA\Tables\Db\Table; use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\ViewMapper; use OCP\EventDispatcher\IEventDispatcher; @@ -33,6 +35,8 @@ final class ContextServiceTest extends TestCase { private MockObject $contextNodeRelMapper; private MockObject $pageMapper; private MockObject $pageContentMapper; + private MockObject $widgetService; + private MockObject $dbConnection; protected function setUp(): void { parent::setUp(); @@ -45,7 +49,8 @@ protected function setUp(): void { $permissionsService = $this->createMock(PermissionsService::class); $userManager = $this->createMock(IUserManager::class); $eventDispatcher = $this->createMock(IEventDispatcher::class); - $dbConnection = $this->createMock(IDBConnection::class); + $this->dbConnection = $this->createMock(IDBConnection::class); + $this->widgetService = $this->createMock(WidgetService::class); $shareService = $this->createMock(ShareService::class); $navigationManager = $this->createMock(INavigationManager::class); $urlGenerator = $this->createMock(IURLGenerator::class); @@ -59,13 +64,14 @@ protected function setUp(): void { $permissionsService, $userManager, $eventDispatcher, - $dbConnection, + $this->dbConnection, $shareService, false, $navigationManager, $urlGenerator, $this->createMock(TableMapper::class), $this->createMock(ViewMapper::class), + $this->widgetService, ); } @@ -156,6 +162,104 @@ public function testUpdateReordersStartPageContentsFromSubmittedNodes(): void { self::assertSame(10, $updatedContext->getPages()[301]['content'][502]['order']); } + public function testDeleteRemovesTheWidgetsOfEveryPage(): void { + $context = new Context(); + $context->id = 7; + $this->contextMapper->method('findById')->with(7, 'user-1')->willReturn($context); + $this->pageMapper->method('getPageIdsForContext')->with(7)->willReturn([301, 302]); + + $this->widgetService->expects($this->once())->method('deleteByPageIds')->with([301, 302]); + $this->pageMapper->expects($this->exactly(2))->method('deleteByPageId'); + $this->contextMapper->expects($this->once())->method('delete')->with($context); + + $this->service->delete(7, 'user-1'); + } + + public function testDeletingANodeUnlinksItsWidgets(): void { + $this->contextNodeRelMapper->method('getRelIdsForNode')->with(11, 0)->willReturn([101, 102]); + + $this->widgetService->expects($this->once())->method('deleteSourcesByNodeRelIds')->with([101, 102]); + $this->pageContentMapper->expects($this->once())->method('deleteByNodeRelIds')->with([101, 102]); + + $this->service->deleteNodeRel(11, 0); + } + + public function testRemovingANodeFromTheContextUnlinksOnlyItsWidgets(): void { + $relation = new ContextNodeRelation(); + $relation->id = 101; + $this->contextNodeRelMapper->method('findById')->with(101)->willReturn($relation); + $this->contextNodeRelMapper->method('delete')->willReturnArgument(0); + $this->pageContentMapper->method('findByNodeRelation')->willReturn([]); + + $this->widgetService->expects($this->once())->method('deleteSourcesByNodeRelIds')->with([101]); + $this->widgetService->expects($this->never())->method('deleteByPageIds'); + $this->widgetService->expects($this->never())->method('deleteByIds'); + + $this->service->removeNodeFromContextAndPages(101); + } + + public function testGetPagesWithWidgetsAddsTheWidgetsOfEachPage(): void { + $context = new Context(); + $context->setPages([ + 301 => ['id' => 301, 'page_type' => Page::TYPE_STARTPAGE, 'content' => []], + 302 => ['id' => 302, 'page_type' => Page::TYPE_GRID, 'content' => []], + ]); + $widget = ['id' => 9, 'type' => 'text', 'title' => null, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => ['text' => 'Hi'], 'source' => null]; + $this->widgetService->method('findByPageIds')->with([301, 302])->willReturn([302 => [$widget]]); + + $pages = $this->service->getPagesWithWidgets($context); + + self::assertSame([], $pages[301]['widgets']); + self::assertSame([$widget], $pages[302]['widgets']); + } + + public function testImportContextRemapsTheSourceOfAWidget(): void { + $table = new Table(); + $table->id = 77; + $table->setOwnership('user-1'); + $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context { + $context->id = 8; + return $context; + }); + $this->contextNodeRelMapper->method('insert')->willReturnCallback(static function (ContextNodeRelation $relation): ContextNodeRelation { + $relation->id = 501; + return $relation; + }); + $insertedPages = []; + $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page) use (&$insertedPages): Page { + $page->id = 600 + count($insertedPages); + $insertedPages[] = $page; + return $page; + }); + + $inserted = []; + $this->widgetService->method('insert')->willReturnCallback(function (...$args) use (&$inserted) { + $inserted[] = $args; + return new \OCA\Tables\Db\Widget(); + }); + + $this->service->importContext($table, [ + 'name' => 'Projects', + 'nodes' => [['id' => 101, 'node_id' => 5, 'node_type' => 0, 'permissions' => 1]], + 'pages' => [[ + 'id' => 301, + 'page_type' => Page::TYPE_GRID, + 'title' => 'Overview', + 'emoji' => '📊', + 'description' => 'At a glance', + 'content' => [], + 'widgets' => [ + ['type' => 'data', 'title' => 'Tasks', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => ['pageSize' => 10], 'source' => ['nodeRelId' => 101]], + ['type' => 'data', 'title' => null, 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 6, 'gridHeight' => 5, 'settings' => [], 'source' => ['nodeRelId' => 999]], + ], + ]], + ], 5); + + self::assertSame(['Overview', '📊', 'At a glance'], [$insertedPages[0]->getTitle(), $insertedPages[0]->getEmoji(), $insertedPages[0]->getDescription()]); + self::assertSame([600, 'data', 'Tasks', ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], ['pageSize' => 10], 501], $inserted[0]); + self::assertNull($inserted[1][5]); + } + private function buildPageContent(int $id, int $pageId, int $nodeRelId, int $order): PageContent { $pageContent = new PageContent(); $pageContent->id = $id; diff --git a/tests/unit/Service/WidgetServiceTest.php b/tests/unit/Service/WidgetServiceTest.php new file mode 100644 index 0000000000..94538b914b --- /dev/null +++ b/tests/unit/Service/WidgetServiceTest.php @@ -0,0 +1,139 @@ +settingMapper = new WidgetSettingMapper($this->connectionAdapter); + $this->service = new WidgetService( + new WidgetMapper($this->connectionAdapter), + $this->settingMapper, + new WidgetSourceMapper($this->connectionAdapter), + ); + } + + protected function tearDown(): void { + $this->service->deleteByPageIds([self::PAGE, self::OTHER_PAGE]); + parent::tearDown(); + } + + public function testInsertedWidgetsAreReadBackWithSettingsAndSource(): void { + $data = $this->service->insert(self::PAGE, 'data', 'Tasks', $this->position(0, 4, 12, 5), [], 31); + $header = $this->service->insert(self::PAGE, 'header', null, $this->position(0, 0, 12, 2), [ + 'text' => 'Projects', + 'showTitle' => false, + 'level' => 2, + 'color' => null, + ], null); + $this->service->insert(self::OTHER_PAGE, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'Other'], null); + + $byPage = $this->service->findByPageIds([self::PAGE]); + + $this->assertSame([self::PAGE], array_keys($byPage)); + $this->assertSame([ + [ + 'id' => $header->getId(), + 'type' => 'header', + 'title' => null, + 'gridX' => 0, + 'gridY' => 0, + 'gridWidth' => 12, + 'gridHeight' => 2, + 'settings' => ['text' => 'Projects', 'showTitle' => false, 'level' => 2, 'color' => null], + 'source' => null, + ], + [ + 'id' => $data->getId(), + 'type' => 'data', + 'title' => 'Tasks', + 'gridX' => 0, + 'gridY' => 4, + 'gridWidth' => 12, + 'gridHeight' => 5, + 'settings' => [], + 'source' => ['nodeRelId' => 31], + ], + ], $byPage[self::PAGE]); + } + + public function testRemovingANodeKeepsTheWidgetWithoutSource(): void { + $kept = $this->service->insert(self::PAGE, 'data', null, $this->position(0, 0, 6, 5), [], 41); + $unlinked = $this->service->insert(self::PAGE, 'data', null, $this->position(6, 0, 6, 5), ['pageSize' => 10], 42); + + $this->service->deleteSourcesByNodeRelIds([42]); + + $widgets = $this->service->findByPageIds([self::PAGE])[self::PAGE]; + $this->assertSame([$kept->getId(), $unlinked->getId()], array_column($widgets, 'id')); + $this->assertSame(['nodeRelId' => 41], $widgets[0]['source']); + $this->assertNull($widgets[1]['source']); + $this->assertSame(['pageSize' => 10], $widgets[1]['settings']); + } + + public function testDeletingPagesDeletesWidgetsSettingsAndSources(): void { + $widget = $this->service->insert(self::PAGE, 'data', null, $this->position(0, 0, 6, 5), ['pageSize' => 10], 51); + $other = $this->service->insert(self::OTHER_PAGE, 'data', null, $this->position(0, 0, 6, 5), ['pageSize' => 20], 52); + + $this->service->deleteByPageIds([self::PAGE]); + + $this->assertSame([], $this->service->findByPageIds([self::PAGE])); + $this->assertSame([], $this->settingMapper->findSettingsByWidgetIds([$widget->getId()])); + $this->assertSame([], (new WidgetSourceMapper($this->connectionAdapter))->findNodeRelIdsByWidgetIds([$widget->getId()])); + $this->assertSame(['nodeRelId' => 52], $this->service->findByPageIds([self::OTHER_PAGE])[self::OTHER_PAGE][0]['source']); + $this->assertSame([$other->getId() => ['pageSize' => 20]], $this->settingMapper->findSettingsByWidgetIds([$other->getId()])); + } + + public function testReplacingSettingsDropsSettingsThatAreNotGiven(): void { + $widget = $this->service->insert(self::PAGE, 'header', null, $this->position(0, 0, 12, 2), ['text' => 'A', 'subtitle' => 'B'], null); + + $this->settingMapper->replaceSettings($widget->getId(), ['text' => 'C']); + + $this->assertSame([$widget->getId() => ['text' => 'C']], $this->settingMapper->findSettingsByWidgetIds([$widget->getId()])); + } + + public function testASettingNameIsUniquePerWidget(): void { + $widget = $this->service->insert(self::PAGE, 'header', null, $this->position(0, 0, 12, 2), ['text' => 'A'], null); + $duplicate = new WidgetSetting(); + $duplicate->setWidgetId($widget->getId()); + $duplicate->setName('text'); + $duplicate->setValue('"B"'); + + try { + $this->settingMapper->insert($duplicate); + $this->fail('A second setting with the same name was stored.'); + } catch (Exception $e) { + $this->assertSame(Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION, $e->getReason()); + } + } + + /** + * @return array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} + */ + private function position(int $x, int $y, int $width, int $height): array { + return ['gridX' => $x, 'gridY' => $y, 'gridWidth' => $width, 'gridHeight' => $height]; + } +} diff --git a/tests/unit/TablesMigratorTest.php b/tests/unit/TablesMigratorTest.php index c68dcf0198..7de0be2617 100644 --- a/tests/unit/TablesMigratorTest.php +++ b/tests/unit/TablesMigratorTest.php @@ -150,6 +150,35 @@ public function testExportSuccess(): void { $this->assertTrue(true); } + public function testExportWritesTheWidgetsOfEveryContextPage(): void { + $user = $this->createMock(IUser::class); + $exportDestination = $this->createMock(IExportDestination::class); + $user->method('getUID')->willReturn('user1'); + $this->favoritesService->method('findAll')->willReturn([]); + $this->tableMapper->method('findAll')->willReturn([]); + $this->columnMapper->method('findAllByTableIds')->willReturn([]); + $this->rowSleeveMapper->method('findAllByTableIds')->willReturn([]); + $this->viewMapper->method('findAll')->willReturn([]); + $this->shareMapper->method('findAllSharesForTablesAndContexts')->willReturn([]); + + $context = new Context(); + $context->id = 3; + $context->setPages([5 => ['id' => 5, 'page_type' => 'grid', 'content' => []]]); + $this->contextMapper->method('findAll')->willReturn([$context]); + $widget = ['id' => 9, 'type' => 'text', 'settings' => ['text' => 'Hi'], 'source' => null]; + $this->contextService->method('getPagesWithWidgets')->with($context) + ->willReturn([5 => ['id' => 5, 'page_type' => 'grid', 'content' => [], 'widgets' => [$widget]]]); + + $files = []; + $exportDestination->method('addFileContents')->willReturnCallback(static function (string $path, string $content) use (&$files): void { + $files[$path] = $content; + }); + $this->migrator->export($user, $exportDestination, $this->createMock(NullOutput::class)); + + $contexts = json_decode($files['contexts.json'], true); + $this->assertSame([$widget], $contexts[0]['pages'][5]['widgets']); + } + public function testExportFailure(): void { $user = $this->createMock(IUser::class); $exportDestination = $this->createMock(IExportDestination::class);