From af7e75b4d14ba50e1335c11f9c896a01566c72f8 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 22 Oct 2026 15:10:23 +0200 Subject: [PATCH] feat(contexts): keep widgets in step with pages and applications Saving a page syncs its widgets by id in one transaction. Deleting a page or an application deletes its widgets and their sources, and removing a table or view from an application deletes only the sources that point at it, so the widget stays. Moving a user's data carries pages, widgets and sources, remapped to the imported nodes and checked like a save. The header, text and data widget types are defined in one place with the schema their settings are checked against. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- lib/Db/ContextMapper.php | 9 +- lib/Db/Page.php | 2 + lib/Db/WidgetSourceMapper.php | 27 ++ lib/Service/ContextService.php | 136 +++++++- lib/Service/GridWidgetTypes.php | 255 +++++++++++++++ lib/Service/WidgetService.php | 195 +++++++++++ lib/UserMigration/TablesMigrator.php | 3 + tests/unit/Db/ContextMapperTest.php | 125 ++++++++ .../Service/ContextPagesRoundTripTest.php | 198 ++++++++++++ tests/unit/Service/ContextServiceTest.php | 303 +++++++++++++++++- tests/unit/Service/GridWidgetTypesTest.php | 163 ++++++++++ tests/unit/Service/WidgetServiceTest.php | 272 ++++++++++++++++ tests/unit/TablesMigratorTest.php | 29 ++ 13 files changed, 1704 insertions(+), 13 deletions(-) create mode 100644 lib/Service/GridWidgetTypes.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/GridWidgetTypesTest.php create mode 100644 tests/unit/Service/WidgetServiceTest.php diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 0da199335e..c8624d51d7 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.technical_name as page_technical_name', 'p.title as page_title', 'p.icon as page_icon', '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' @@ -72,6 +72,7 @@ protected function getFindContextBaseQuery(?string $userId): IQueryBuilder { )); $qb->orderBy('pc.order', 'ASC'); + $qb->addOrderBy('r.id', 'ASC'); return $qb; } @@ -131,7 +132,11 @@ 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']]['technical_name'] = $item['page_technical_name']; + $carry[$item['page_id']]['title'] = $item['page_title']; + $carry[$item['page_id']]['icon'] = $item['page_icon']; + $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 44cff5db53..d705d4389e 100644 --- a/lib/Db/Page.php +++ b/lib/Db/Page.php @@ -29,6 +29,8 @@ class Page extends Entity implements \JsonSerializable { public const TYPE_GRID = 'grid'; public const START_PAGE_NAME = 'dashboard'; public const DEFAULT_NAME_PREFIX = 'page_'; + public const TITLE_MAX_LENGTH = 200; + public const DESCRIPTION_MAX_LENGTH = 2000; protected ?int $contextId = null; protected ?string $pageType = null; diff --git a/lib/Db/WidgetSourceMapper.php b/lib/Db/WidgetSourceMapper.php index 9041e7d849..6b31e3cdd3 100644 --- a/lib/Db/WidgetSourceMapper.php +++ b/lib/Db/WidgetSourceMapper.php @@ -50,6 +50,33 @@ public function findNodeRelIdsByWidgetIds(array $widgetIds): array { return $sources; } + /** + * The given node relations that belong to the application of a page. + * + * @param int[] $nodeRelIds + * @return int[] + * @throws Exception + */ + public function filterNodeRelIdsOfPageContext(int $pageId, array $nodeRelIds): array { + if ($nodeRelIds === []) { + return []; + } + $qb = $this->db->getQueryBuilder(); + $qb->select('r.id') + ->from('tables_contexts_rel_context_node', 'r') + ->innerJoin('r', 'tables_contexts_page', 'p', $qb->expr()->eq('p.context_id', 'r.context_id')) + ->where($qb->expr()->eq('p.id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->in('r.id', $qb->createNamedParameter($nodeRelIds, IQueryBuilder::PARAM_INT_ARRAY))); + + $result = $qb->executeQuery(); + $ids = []; + while ($row = $result->fetchAssociative()) { + $ids[] = (int)$row['id']; + } + $result->closeCursor(); + return $ids; + } + /** * Points a widget at a node relation, or removes its source when null. * diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index d3c161163b..b71ac77127 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -25,6 +25,7 @@ use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Helper\IconHelper; use OCA\Tables\Model\ColumnSettings; use OCA\Tables\Model\ContextScheme; use OCA\Tables\Model\FilterSet; @@ -63,6 +64,8 @@ public function __construct( protected IURLGenerator $urlGenerator, private TableMapper $tableMapper, private ViewMapper $viewMapper, + private WidgetService $widgetService, + private GridWidgetTypes $gridWidgetTypes, ) { } @@ -320,6 +323,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); @@ -333,6 +337,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,9 +437,12 @@ public function addNodeToContextAndStartpage(int $contextId, int $nodeId, int $n * @throws Exception */ public function removeNodeFromContextAndPages(int $nodeRelationId): array { - $nodeRelation = $this->removeNodeFromContextById($nodeRelationId); - $contents = $this->removeNodeRelFromAllPages($nodeRelation); - return [$nodeRelation, $contents]; + return $this->atomic(function () use ($nodeRelationId): array { + $this->widgetService->deleteSourcesByNodeRelIds([$nodeRelationId]); + $nodeRelation = $this->removeNodeFromContextById($nodeRelationId); + $contents = $this->removeNodeRelFromAllPages($nodeRelation); + return [$nodeRelation, $contents]; + }, $this->dbc); } /** @@ -618,15 +626,26 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C // insert nodes $nodeRelMap = []; + // the relations of the table being imported, the only node a widget source can keep + $sourceRelMap = []; if (!empty($context['nodes'])) { foreach ($context['nodes'] as $nodeArr) { + $nodeType = $nodeArr['node_type'] ?? $nodeArr['nodeType'] ?? 0; $contextNodeRel = new ContextNodeRelation(); $contextNodeRel->setContextId($newContext->getId()); $contextNodeRel->setNodeId($table->getId()); - $contextNodeRel->setNodeType($nodeArr['node_type'] ?? $nodeArr['nodeType'] ?? 0); + $contextNodeRel->setNodeType($nodeType); $contextNodeRel->setPermissions($nodeArr['permissions'] ?? 0); $contextNodeRel = $this->contextNodeRelMapper->insert($contextNodeRel); - $nodeRelMap[$nodeArr['id'] ?? null] = $contextNodeRel; + $oldRelId = self::importedKey($nodeArr['id'] ?? null); + if ($oldRelId === null) { + continue; + } + $nodeRelMap[$oldRelId] = $contextNodeRel; + $oldNodeId = $nodeArr['node_id'] ?? $nodeArr['nodeId'] ?? null; + if (is_numeric($nodeType) && (int)$nodeType === Application::NODE_TYPE_TABLE && is_numeric($oldNodeId) && (int)$oldNodeId === $tableOldId) { + $sourceRelMap[$oldRelId] = $contextNodeRel; + } } } @@ -638,6 +657,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($this->importedText($pageArr['title'] ?? null, Page::TITLE_MAX_LENGTH, 'page title')); + $page->setIcon($this->importedIcon($pageArr['icon'] ?? null)); + $page->setDescription($this->importedText($pageArr['description'] ?? null, Page::DESCRIPTION_MAX_LENGTH, 'page description')); if ($technicalNames[$pageKey] === null) { $page = $this->pageMapper->insertWithDefaultName($page); } else { @@ -649,15 +671,26 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C // insert page conten if (!empty($pageArr['content'])) { foreach ($pageArr['content'] as $contentArr) { + $oldNodeRelId = self::importedKey($contentArr['node_rel_id'] ?? $contentArr['nodeRelId'] ?? null); + $newNodeRel = $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; + if ($newNodeRel === null) { + continue; + } $pageContent = new PageContent(); $pageContent->setPageId($page->getId()); - $oldNodeRelId = $contentArr['node_rel_id'] ?? $contentArr['nodeRelId'] ?? null; - $newNodeRel = $nodeRelMap[$oldNodeRelId] ?? null; - $pageContent->setNodeRelId($newNodeRel ? $newNodeRel->getId() : null); + $pageContent->setNodeRelId($newNodeRel->getId()); $pageContent->setOrder($contentArr['order'] ?? 100); $this->pageContentMapper->insert($pageContent); } } + + $widgets = is_array($pageArr['widgets'] ?? null) ? array_values($pageArr['widgets']) : []; + if (count($widgets) > GridWidgetTypes::MAX_WIDGETS_PER_PAGE) { + $this->logger->warning('Skipping ' . (count($widgets) - GridWidgetTypes::MAX_WIDGETS_PER_PAGE) . ' imported widgets: a page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . '.'); + } + foreach (array_slice($widgets, 0, GridWidgetTypes::MAX_WIDGETS_PER_PAGE) as $index => $widgetArr) { + $this->importWidget($page->getId(), $index, $widgetArr, $sourceRelMap); + } } } @@ -694,6 +727,91 @@ private function importedPageNames(array $pages): array { return $names; } + /** + * 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 of the table being imported, and dropped when it showed another node. A + * widget that would not be saved through the API, for its type, settings, title, + * description, place on the grid or the shape of its source, is skipped. + * + * @param array $nodeRelMap imported node relations of the table by their exported id + * @throws Exception + * @throws \JsonException + */ + private function importWidget(int $pageId, int $index, mixed $widgetArr, array $nodeRelMap): void { + if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { + return; + } + try { + $hasSource = $this->gridWidgetTypes->get($widgetArr['type'])['hasSource']; + $settings = $this->gridWidgetTypes->sanitizeSettings($widgetArr['type'], $widgetArr['settings'] ?? null); + $title = $this->gridWidgetTypes->validateTitle($index, $widgetArr['title'] ?? null); + $description = $this->gridWidgetTypes->validateDescription($index, $widgetArr['description'] ?? null); + $position = $this->gridWidgetTypes->validatePosition($index, $widgetArr); + $source = $widgetArr['source'] ?? null; + $oldNodeRelId = is_array($source) ? ($source['nodeRelId'] ?? null) : null; + if (($source !== null && !is_array($source)) || ($oldNodeRelId !== null && !is_int($oldNodeRelId))) { + throw new BadRequestError('The source of widget ' . $index . ' must be an object with a numeric nodeRelId.'); + } + } catch (BadRequestError $e) { + $this->logger->warning('Skipping an imported widget: ' . $e->getMessage(), ['exception' => $e]); + return; + } + $newNodeRel = $hasSource && $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; + $this->widgetService->insert( + $pageId, + $widgetArr['type'], + $title, + $position, + $settings, + $newNodeRel?->getId(), + $this->importedIcon($widgetArr['icon'] ?? null), + $description, + ); + } + + /** + * An exported id usable as an array key, or null for a value of another type. + */ + private static function importedKey(mixed $id): int|string|null { + return is_int($id) || is_string($id) ? $id : null; + } + + /** + * An exported text, trimmed, or null when it is empty, not a text or too long. + */ + private function importedText(mixed $text, int $maxLength, string $what): ?string { + if ($text === null) { + return null; + } + if (!is_string($text) || mb_strlen(trim($text)) > $maxLength) { + $this->logger->warning('Dropping an imported ' . $what . ': it is not a text of at most ' . $maxLength . ' characters.'); + return null; + } + $text = trim($text); + return $text === '' ? null : $text; + } + + /** + * An exported icon name, or null when this instance has no such icon. + */ + private function importedIcon(mixed $icon): ?string { + return is_string($icon) && IconHelper::isValid($icon) ? $icon : null; + } + /** * @param int $contextId * @@ -743,7 +861,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/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php new file mode 100644 index 0000000000..8864565440 --- /dev/null +++ b/lib/Service/GridWidgetTypes.php @@ -0,0 +1,255 @@ +, + * required?: bool, + * } + * @psalm-type TablesWidgetType = array{ + * type: string, + * title: string, + * defaultWidth: int, + * defaultHeight: int, + * hasSource: bool, + * settings: array, + * } + */ +class GridWidgetTypes { + public const TYPE_HEADER = 'header'; + public const TYPE_TEXT = 'text'; + public const TYPE_DATA = 'data'; + + public const GRID_COLUMNS = 12; + public const GRID_MAX_HEIGHT = 100; + public const GRID_MAX_Y = 10000; + public const TITLE_MAX_LENGTH = 200; + public const DESCRIPTION_MAX_LENGTH = 2000; + public const MAX_WIDGETS_PER_PAGE = 100; + /** Bytes of the settings as stored JSON, below the 64 KB of a MySQL TEXT column. */ + public const SETTINGS_MAX_BYTES = 60000; + + private const HEX_COLOR = '/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/D'; + + public function __construct( + private readonly IL10N $l10n, + ) { + } + + /** + * @return array + */ + public function all(): array { + return [ + self::TYPE_HEADER => [ + 'type' => self::TYPE_HEADER, + 'title' => $this->l10n->t('Header'), + 'defaultWidth' => 12, + 'defaultHeight' => 2, + 'hasSource' => false, + 'settings' => [ + 'heading' => ['type' => 'string', 'title' => $this->l10n->t('Heading'), 'default' => '', 'required' => true], + 'subheading' => ['type' => 'string', 'title' => $this->l10n->t('Subheading'), 'default' => ''], + 'textAlign' => ['type' => 'enum', 'title' => $this->l10n->t('Text alignment'), 'default' => 'left', 'options' => [ + ['value' => 'left', 'label' => $this->l10n->t('Start')], + ['value' => 'center', 'label' => $this->l10n->t('Centered')], + ['value' => 'right', 'label' => $this->l10n->t('End')], + ]], + 'backgroundColor' => ['type' => 'color', 'title' => $this->l10n->t('Background color'), 'default' => ''], + 'textColor' => ['type' => 'color', 'title' => $this->l10n->t('Text color'), 'default' => ''], + ], + ], + self::TYPE_TEXT => [ + 'type' => self::TYPE_TEXT, + 'title' => $this->l10n->t('Description'), + 'defaultWidth' => 6, + 'defaultHeight' => 2, + 'hasSource' => false, + 'settings' => [ + 'text' => ['type' => 'text', 'title' => $this->l10n->t('Text'), 'default' => ''], + ], + ], + self::TYPE_DATA => [ + 'type' => self::TYPE_DATA, + 'title' => $this->l10n->t('Table or view'), + 'defaultWidth' => 12, + 'defaultHeight' => 5, + 'hasSource' => true, + 'settings' => [], + ], + ]; + } + + /** + * @return TablesWidgetType + * @throws BadRequestError + */ + public function get(string $type): array { + $types = $this->all(); + if (!isset($types[$type])) { + throw new BadRequestError('Unknown widget type "' . $type . '", expected one of: ' . implode(', ', array_keys($types))); + } + return $types[$type]; + } + + /** + * Checks the settings of a widget against the schema of its type and returns them + * with defaults filled in and unknown settings dropped. Stored as JSON they take at + * most {@see self::SETTINGS_MAX_BYTES} bytes. + * + * @return array + * @throws BadRequestError + */ + public function sanitizeSettings(string $type, mixed $settings): array { + $schema = $this->get($type)['settings']; + $settings ??= []; + if (!is_array($settings)) { + throw new BadRequestError('The settings of a ' . $type . ' widget must be an object.'); + } + $sanitized = []; + foreach ($schema as $name => $property) { + $value = array_key_exists($name, $settings) ? $settings[$name] : $property['default']; + $sanitized[$name] = $this->sanitizeValue($type . '.' . $name, $property, $value); + } + $json = json_encode((object)$sanitized); + if ($json === false) { + throw new BadRequestError('The settings of a ' . $type . ' widget must be valid UTF-8 text.'); + } + if (strlen($json) > self::SETTINGS_MAX_BYTES) { + throw new BadRequestError('The settings of a ' . $type . ' widget take more than ' . self::SETTINGS_MAX_BYTES . ' bytes.'); + } + return $sanitized; + } + + /** + * @param TablesWidgetSettingSchema $property + * @return TablesWidgetSettingValue + * @throws BadRequestError + */ + private function sanitizeValue(string $label, array $property, mixed $value): string|bool|int|null { + return match ($property['type']) { + 'string', 'text' => $this->sanitizeText($label, $value, $property['required'] ?? false), + 'color' => $this->sanitizeColor($label, $value), + 'boolean' => is_bool($value) ? $value : throw new BadRequestError('Widget setting ' . $label . ' must be true or false.'), + 'enum' => $this->sanitizeEnum($label, $value, array_column($property['options'] ?? [], 'value')), + }; + } + + /** + * @throws BadRequestError + */ + private function sanitizeText(string $label, mixed $value, bool $isRequired): string { + $value ??= ''; + if (!is_string($value)) { + throw new BadRequestError('Widget setting ' . $label . ' must be a string.'); + } + if ($isRequired && trim($value) === '') { + throw new BadRequestError('Widget setting ' . $label . ' is required.'); + } + return $value; + } + + /** + * @throws BadRequestError + */ + private function sanitizeColor(string $label, mixed $value): string { + if ($value === null || $value === '') { + return ''; + } + if (!is_string($value) || !preg_match(self::HEX_COLOR, $value)) { + throw new BadRequestError('Widget setting ' . $label . ' must be a hex color.'); + } + return $value; + } + + /** + * @param list $allowed + * @throws BadRequestError + */ + private function sanitizeEnum(string $label, mixed $value, array $allowed): string { + if (!is_string($value) || !in_array($value, $allowed, true)) { + throw new BadRequestError('Widget setting ' . $label . ' must be one of: ' . implode(', ', $allowed) . '.'); + } + return $value; + } + + /** + * The title of a widget, trimmed, or null when it has none. + * + * @throws BadRequestError + */ + public function validateTitle(int $index, mixed $title): ?string { + if ($title === null) { + return null; + } + if (!is_string($title) || mb_strlen(trim($title)) > self::TITLE_MAX_LENGTH) { + throw new BadRequestError('The title of widget ' . $index . ' must be a text of at most ' . self::TITLE_MAX_LENGTH . ' characters.'); + } + $title = trim($title); + return $title === '' ? null : $title; + } + + /** + * The description of a widget, trimmed, or null when it has none. + * + * @throws BadRequestError + */ + public function validateDescription(int $index, mixed $description): ?string { + if ($description === null) { + return null; + } + if (!is_string($description) || mb_strlen(trim($description)) > self::DESCRIPTION_MAX_LENGTH) { + throw new BadRequestError('The description of widget ' . $index . ' must be a text of at most ' . self::DESCRIPTION_MAX_LENGTH . ' characters.'); + } + $description = trim($description); + return $description === '' ? null : $description; + } + + /** + * The place of a widget on a grid of {@see self::GRID_COLUMNS} columns. + * + * @param array $widget + * @return array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} + * @throws BadRequestError + */ + public function validatePosition(int $index, array $widget): array { + $limits = [ + 'gridX' => [0, self::GRID_COLUMNS - 1], + 'gridY' => [0, self::GRID_MAX_Y], + 'gridWidth' => [1, self::GRID_COLUMNS], + 'gridHeight' => [1, self::GRID_MAX_HEIGHT], + ]; + $position = []; + foreach ($limits as $name => [$minimum, $maximum]) { + $value = $widget[$name] ?? null; + if (!is_int($value) || $value < $minimum || $value > $maximum) { + throw new BadRequestError('Widget ' . $index . ' needs ' . $name . ' as a whole number from ' . $minimum . ' to ' . $maximum . '.'); + } + $position[$name] = $value; + } + if ($position['gridX'] + $position['gridWidth'] > self::GRID_COLUMNS) { + throw new BadRequestError('Widget ' . $index . ' does not fit in ' . self::GRID_COLUMNS . ' columns.'); + } + return $position; + } +} diff --git a/lib/Service/WidgetService.php b/lib/Service/WidgetService.php new file mode 100644 index 0000000000..b989d40281 --- /dev/null +++ b/lib/Service/WidgetService.php @@ -0,0 +1,195 @@ +> 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); + $sources = $this->widgetSourceMapper->findNodeRelIdsByWidgetIds($widgetIds); + + $byPage = []; + foreach ($widgets as $widget) { + $id = $widget->getId(); + $byPage[$widget->getPageId()][] = [ + 'id' => $id, + 'type' => $widget->getType(), + 'title' => $widget->getTitle(), + 'icon' => $widget->getIcon(), + 'description' => $widget->getDescription(), + 'gridX' => $widget->getGridX(), + 'gridY' => $widget->getGridY(), + 'gridWidth' => $widget->getGridWidth(), + 'gridHeight' => $widget->getGridHeight(), + 'settings' => $widget->getSettingsObject(), + '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 \InvalidArgumentException when the source is not a node of the application of the page + * @throws Exception + * @throws \JsonException + */ + public function insert(int $pageId, string $type, ?string $title, array $position, array $settings, ?int $nodeRelId, ?string $icon = null, ?string $description = null): Widget { + $this->assertSourcesOfPage($pageId, [$nodeRelId]); + return $this->insertWidget($pageId, $type, $title, $position, $settings, $nodeRelId, $icon, $description); + } + + /** + * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position + * @param array $settings + * @throws Exception + * @throws \JsonException + */ + private function insertWidget(int $pageId, string $type, ?string $title, array $position, array $settings, ?int $nodeRelId, ?string $icon, ?string $description): Widget { + $widget = new Widget(); + $widget->setPageId($pageId); + $this->applyFields($widget, $type, $title, $position, $settings, $icon, $description); + $widget = $this->widgetMapper->insert($widget); + + $this->widgetSourceMapper->setSource($widget->getId(), $nodeRelId); + return $widget; + } + + /** + * Makes the widgets of a page match the given list: widgets with an id are updated, + * widgets without one are inserted and widgets of the page that are not listed are + * deleted. A widget listed without an icon or description has none. + * + * @param list, nodeRelId: ?int}> $widgets + * @throws \InvalidArgumentException when an id is not one of a widget of the page, or a + * source is not a node of the application of the page + * @throws Exception + * @throws \JsonException + */ + public function syncPage(int $pageId, array $widgets): void { + $this->assertSourcesOfPage($pageId, array_column($widgets, 'nodeRelId')); + $existing = []; + foreach ($this->widgetMapper->findByPageIds([$pageId]) as $widget) { + $existing[$widget->getId()] = $widget; + } + $listedIds = []; + foreach ($widgets as $data) { + if ($data['id'] === null) { + continue; + } + if (!isset($existing[$data['id']])) { + throw new \InvalidArgumentException('Widget ' . $data['id'] . ' is not on page ' . $pageId); + } + $listedIds[] = $data['id']; + } + $this->deleteByIds(array_values(array_diff(array_keys($existing), $listedIds))); + + foreach ($widgets as $data) { + if ($data['id'] === null) { + $this->insertWidget($pageId, $data['type'], $data['title'], $data['position'], $data['settings'], $data['nodeRelId'], $data['icon'] ?? null, $data['description'] ?? null); + continue; + } + // A new entity would not mark a field set to its default, such as a null title, as changed. + $widget = $existing[$data['id']]; + $this->applyFields($widget, $data['type'], $data['title'], $data['position'], $data['settings'], $data['icon'] ?? null, $data['description'] ?? null); + if ($widget->getUpdatedFields() !== []) { + $this->widgetMapper->update($widget); + } + $this->widgetSourceMapper->setSource($data['id'], $data['nodeRelId']); + } + } + + /** + * Deletes the widgets of the given pages with their 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->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 list $nodeRelIds + * @throws \InvalidArgumentException when one is not a node of the application of the page + * @throws Exception + */ + private function assertSourcesOfPage(int $pageId, array $nodeRelIds): void { + $nodeRelIds = array_values(array_unique(array_filter($nodeRelIds, static fn (?int $id): bool => $id !== null))); + $foreign = array_diff($nodeRelIds, $this->widgetSourceMapper->filterNodeRelIdsOfPageContext($pageId, $nodeRelIds)); + if ($foreign !== []) { + throw new \InvalidArgumentException('Node relation ' . implode(', ', $foreign) . ' is not part of the application of page ' . $pageId); + } + } + + /** + * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position + * @param array $settings + * @throws \JsonException + */ + private function applyFields(Widget $widget, string $type, ?string $title, array $position, array $settings, ?string $icon, ?string $description): void { + $widget->setType($type); + $widget->setTitle($title); + $widget->setIcon($icon); + $widget->setDescription($description); + $widget->setGridX($position['gridX']); + $widget->setGridY($position['gridY']); + $widget->setGridWidth($position['gridWidth']); + $widget->setGridHeight($position['gridHeight']); + $widget->setSettingsArray($settings); + } +} 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..6d1cc09870 --- /dev/null +++ b/tests/unit/Db/ContextMapperTest.php @@ -0,0 +1,125 @@ +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, 'dashboard', null, null, null); + $firstContent = $this->addContent($startPage, $first, 10); + $secondContent = $this->addContent($startPage, $second, 20); + $gridPage = $this->addPage($context, Page::TYPE_GRID, 'overview', 'Overview', 'star', '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, + 'technical_name' => 'overview', + 'title' => 'Overview', + 'icon' => 'star', + 'description' => 'Open work at a glance', + ], $pages[$gridPage->getId()]); + $this->assertNull($pages[$startPage->getId()]['title']); + $this->assertSame('dashboard', $pages[$startPage->getId()]['technical_name']); + $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 $technicalName, ?string $title, ?string $icon, ?string $description): Page { + $page = new Page(); + $page->setContextId($context->getId()); + $page->setPageType($type); + $page->setTechnicalName($technicalName); + $page->setTitle($title); + $page->setIcon($icon); + $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..1f4b7afb2b --- /dev/null +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -0,0 +1,198 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $userHelper = $this->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 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, + new GridWidgetTypes($l10n), + ); + } + + 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', 'Overview', 'star', 'At a glance'], [$gridPages[0]['technical_name'], $gridPages[0]['title'], $gridPages[0]['icon'], $gridPages[0]['description']]); + $startPages = array_values(array_filter($importedPages, static fn (array $page): bool => $page['page_type'] === Page::TYPE_STARTPAGE)); + $this->assertSame('dashboard', $startPages[0]['technical_name']); + + $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(json_encode($expected), json_encode($withoutIds($gridPages[0]['widgets']))); + } + + public function testAnUnknownIconIsDroppedOnImport(): void { + [$context, $relation, $gridPageId] = $this->createContextWithGridPage(); + $table = new Table(); + $table->id = 4243; + $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'] = 4243; + $exported['pages'][$gridPageId]['icon'] = 'no-such-icon'; + $exported['pages'][$gridPageId]['widgets'][0]['icon'] = '../star'; + + $imported = $this->service->importContext($table, $exported, 4243); + $this->assertNotNull($imported); + $this->contextIds[] = $imported->getId(); + + $importedPages = $this->service->getPagesWithWidgets($this->contextMapper->findById($imported->getId(), self::OWNER)); + $gridPages = array_values(array_filter($importedPages, static fn (array $page): bool => $page['page_type'] === Page::TYPE_GRID)); + $this->assertNull($gridPages[0]['icon']); + $this->assertNull($gridPages[0]['widgets'][0]['icon']); + $this->assertSame('Where to start', $gridPages[0]['widgets'][0]['description']); + } + + 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('data', $widgets[1]['type']); + + $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->setTechnicalName('overview'); + $page->setTitle('Overview'); + $page->setIcon('star'); + $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], ['heading' => 'Projects', 'subheading' => '', 'textAlign' => 'center', 'backgroundColor' => '#123456', 'textColor' => ''], null, 'home', 'Where to start'); + $this->widgetService->insert($page->getId(), 'data', null, ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], [], $relation->getId()); + + return [$context, $relation, $page->getId()]; + } +} diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 4e290e699c..5fb9d9df5c 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -11,6 +11,7 @@ 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; @@ -21,6 +22,7 @@ use OCA\Tables\Db\ViewMapper; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; +use OCP\IL10N; use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\IUserManager; @@ -34,6 +36,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(); @@ -46,7 +50,10 @@ 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); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); $shareService = $this->createMock(ShareService::class); $navigationManager = $this->createMock(INavigationManager::class); $urlGenerator = $this->createMock(IURLGenerator::class); @@ -60,13 +67,15 @@ protected function setUp(): void { $permissionsService, $userManager, $eventDispatcher, - $dbConnection, + $this->dbConnection, $shareService, false, $navigationManager, $urlGenerator, $this->createMock(TableMapper::class), $this->createMock(ViewMapper::class), + $this->widgetService, + new GridWidgetTypes($l10n), ); } @@ -157,6 +166,160 @@ 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 testRemovingANodeDeletesItsSourcesFirstInOneTransaction(): void { + $relation = new ContextNodeRelation(); + $relation->id = 101; + $calls = []; + $this->dbConnection->method('beginTransaction')->willReturnCallback(function () use (&$calls): void { + $calls[] = 'begin'; + }); + $this->dbConnection->method('commit')->willReturnCallback(function () use (&$calls): void { + $calls[] = 'commit'; + }); + $this->widgetService->method('deleteSourcesByNodeRelIds')->willReturnCallback(function () use (&$calls): void { + $calls[] = 'sources'; + }); + $this->contextNodeRelMapper->method('findById')->with(101)->willReturn($relation); + $this->contextNodeRelMapper->method('delete')->willReturnCallback(function (ContextNodeRelation $deleted) use (&$calls): ContextNodeRelation { + $calls[] = 'relation'; + return $deleted; + }); + $this->pageContentMapper->method('findByNodeRelation')->willReturnCallback(function () use (&$calls): array { + $calls[] = 'contents'; + return []; + }); + + $this->service->removeNodeFromContextAndPages(101); + + self::assertSame(['begin', 'sources', 'relation', 'contents', 'commit'], $calls); + } + + public function testAFailedNodeRemovalRollsBackTheDeletedSources(): void { + $this->contextNodeRelMapper->method('findById')->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('gone')); + $this->widgetService->expects($this->once())->method('deleteSourcesByNodeRelIds')->with([101]); + $this->dbConnection->expects($this->once())->method('rollBack'); + $this->dbConnection->expects($this->never())->method('commit'); + + $this->expectException(\OCP\AppFramework\Db\DoesNotExistException::class); + $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 testImportContextRemapsSourcesAndSkipsInvalidWidgets(): 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; + }); + + $insertedContents = []; + $this->pageContentMapper->method('insert')->willReturnCallback(static function (PageContent $content) use (&$insertedContents): PageContent { + $insertedContents[] = [$content->getPageId(), $content->getNodeRelId(), $content->getOrder()]; + return $content; + }); + + $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, + 'technical_name' => 'overview', + 'title' => 'Overview', + 'icon' => 'star', + 'description' => 'At a glance', + 'content' => [['node_rel_id' => 101, 'order' => 10], ['node_rel_id' => 999, 'order' => 20]], + 'widgets' => [ + ['type' => 'data', 'title' => 'Tasks', 'icon' => 'star', 'description' => 'Open tasks', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => ['unknown' => 10], 'source' => ['nodeRelId' => 101]], + ['type' => 'data', 'title' => null, 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 6, 'gridHeight' => 5, 'settings' => [], 'source' => ['nodeRelId' => 999]], + ['type' => 'text', 'title' => null, 'gridX' => 6, 'gridY' => 7, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => ['text' => 'Hi'], 'source' => ['nodeRelId' => 101]], + ['type' => 'header', 'title' => null, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2, 'settings' => ['heading' => ''], 'source' => null], + ['type' => 'chart', 'title' => null, 'gridX' => 0, 'gridY' => 9, 'gridWidth' => 12, 'gridHeight' => 2, 'settings' => [], 'source' => null], + ['type' => 'text', 'title' => null, 'gridX' => 8, 'gridY' => 11, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => [], 'source' => null], + ['type' => 'text', 'title' => null, 'gridX' => 0, 'gridY' => 11, 'gridWidth' => 6, 'gridHeight' => 101, 'settings' => [], 'source' => null], + ['type' => 'text', 'title' => str_repeat('a', 201), 'gridX' => 0, 'gridY' => 11, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => [], 'source' => null], + ['type' => 'text', 'title' => ' Notes ', 'gridX' => 6, 'gridY' => 11, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => [], 'source' => null], + ], + ]], + ], 5); + + self::assertSame(['overview', 'Overview', 'star', 'At a glance'], [$insertedPages[0]->getTechnicalName(), $insertedPages[0]->getTitle(), $insertedPages[0]->getIcon(), $insertedPages[0]->getDescription()]); + self::assertCount(4, $inserted); + self::assertSame(['Notes', ['gridX' => 6, 'gridY' => 11, 'gridWidth' => 6, 'gridHeight' => 2]], [$inserted[3][2], $inserted[3][3]]); + self::assertSame([[600, 501, 10]], $insertedContents); + self::assertSame([600, 'data', 'Tasks', ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], [], 501, 'star', 'Open tasks'], $inserted[0]); + self::assertNull($inserted[1][5]); + self::assertSame([600, 'text', null, ['gridX' => 6, 'gridY' => 7, 'gridWidth' => 6, 'gridHeight' => 2], ['text' => 'Hi'], null, null, null], $inserted[2]); + } + public function testANewApplicationGetsAStartPageNamedDashboard(): void { $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context { $context->id = 7; @@ -268,4 +431,140 @@ private function buildPageContent(int $id, int $pageId, int $nodeRelId, int $ord return $pageContent; } + + /** + * Mocks the inserts of an import and returns what they write. + * + * @return \ArrayObject> pages, relations and widgets as inserted + */ + private function recordImport(): \ArrayObject { + $record = new \ArrayObject(['pages' => [], 'relations' => [], 'widgets' => []]); + $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context { + $context->id = 8; + return $context; + }); + $this->contextNodeRelMapper->method('insert')->willReturnCallback(static function (ContextNodeRelation $relation) use ($record): ContextNodeRelation { + $relation->id = 501 + count($record['relations']); + $record['relations'] = [...$record['relations'], $relation]; + return $relation; + }); + $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page) use ($record): Page { + $page->id = 600 + count($record['pages']); + $record['pages'] = [...$record['pages'], $page]; + return $page; + }); + $this->pageMapper->method('insertWithDefaultName')->willReturnCallback(static function (Page $page) use ($record): Page { + $page->id = 600 + count($record['pages']); + $record['pages'] = [...$record['pages'], $page]; + return $page; + }); + $this->widgetService->method('insert')->willReturnCallback(static function (...$args) use ($record): \OCA\Tables\Db\Widget { + $record['widgets'] = [...$record['widgets'], $args]; + return new \OCA\Tables\Db\Widget(); + }); + return $record; + } + + /** + * @return array + */ + private static function dataWidget(int $gridY, mixed $source): array { + return ['type' => 'data', 'title' => null, 'gridX' => 0, 'gridY' => $gridY, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => [], 'source' => $source]; + } + + public function testAnImportedWidgetSourceKeepsOnlyTheTableBeingImported(): void { + $exported = [ + 'name' => 'Projects', + 'nodes' => [ + ['id' => 101, 'node_id' => 5, 'node_type' => 0, 'permissions' => 1], + ['id' => 102, 'node_id' => 6, 'node_type' => 0, 'permissions' => 1], + ], + 'pages' => [[ + 'id' => 301, + 'page_type' => Page::TYPE_GRID, + 'technical_name' => 'overview', + 'content' => [], + 'widgets' => [self::dataWidget(0, ['nodeRelId' => 101]), self::dataWidget(5, ['nodeRelId' => 102])], + ]], + ]; + $record = $this->recordImport(); + $table = new Table(); + $table->id = 78; + $table->setOwnership('user-1'); + + // the migrator imports the application once per table it holds; this is table 6 + $this->service->importContext($table, $exported, 6); + + $relationOfTable6 = $record['relations'][1]; + self::assertSame([null, $relationOfTable6->getId()], array_column($record['widgets'], 5)); + } + + public function testABrokenExportFileSkipsTheWidgetInsteadOfFailing(): void { + $record = $this->recordImport(); + $table = new Table(); + $table->id = 77; + $table->setOwnership('user-1'); + + $this->service->importContext($table, [ + 'name' => 'Projects', + 'nodes' => [['id' => [101], 'node_id' => 5, 'node_type' => 0, 'permissions' => 1], ['id' => 102, 'node_id' => 5, 'node_type' => 0, 'permissions' => 1]], + 'pages' => [[ + 'id' => 301, + 'page_type' => Page::TYPE_GRID, + 'technical_name' => 'overview', + 'title' => ['Overview'], + 'description' => str_repeat('a', 2001), + 'content' => [['node_rel_id' => [102], 'order' => 10]], + 'widgets' => [ + self::dataWidget(0, ['nodeRelId' => [102]]), + self::dataWidget(5, 'a source'), + self::dataWidget(10, ['nodeRelId' => '102']), + ['type' => 'text', 'title' => null, 'description' => str_repeat('a', 2001), 'gridX' => 0, 'gridY' => 15, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => [], 'source' => null], + self::dataWidget(20, ['nodeRelId' => 102]), + ], + ]], + ], 5); + + self::assertSame([null, null], [$record['pages'][0]->getTitle(), $record['pages'][0]->getDescription()]); + self::assertCount(1, $record['widgets']); + self::assertSame([20, 502], [$record['widgets'][0][3]['gridY'], $record['widgets'][0][5]]); + } + + public function testImportedPageTextsAreTrimmed(): void { + $record = $this->recordImport(); + $table = new Table(); + $table->id = 77; + $table->setOwnership('user-1'); + + $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, 'technical_name' => 'one', 'title' => ' Overview ', 'description' => ' ', 'content' => []], + ['id' => 302, 'page_type' => Page::TYPE_GRID, 'technical_name' => 'two', 'title' => str_repeat('a', 201), 'description' => str_repeat('a', 2000), 'content' => []], + ], + ], 5); + + self::assertSame(['Overview', null], [$record['pages'][0]->getTitle(), $record['pages'][0]->getDescription()]); + self::assertSame([null, str_repeat('a', 2000)], [$record['pages'][1]->getTitle(), $record['pages'][1]->getDescription()]); + } + + public function testAnImportedPageKeepsAtMostTheMaximumNumberOfWidgets(): void { + $record = $this->recordImport(); + $table = new Table(); + $table->id = 77; + $table->setOwnership('user-1'); + $widgets = []; + for ($i = 0; $i < GridWidgetTypes::MAX_WIDGETS_PER_PAGE + 5; $i++) { + $widgets[] = ['type' => 'text', 'title' => null, 'gridX' => 0, 'gridY' => $i * 2, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => [], 'source' => null]; + } + + $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, 'technical_name' => 'one', 'content' => [], 'widgets' => $widgets]], + ], 5); + + self::assertCount(GridWidgetTypes::MAX_WIDGETS_PER_PAGE, $record['widgets']); + } } diff --git a/tests/unit/Service/GridWidgetTypesTest.php b/tests/unit/Service/GridWidgetTypesTest.php new file mode 100644 index 0000000000..7a698a5956 --- /dev/null +++ b/tests/unit/Service/GridWidgetTypesTest.php @@ -0,0 +1,163 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text): string => '[' . $text . ']'); + $this->types = new GridWidgetTypes($l10n); + } + + public function testEveryTypeDescribesItsSizeSourceAndSettings(): void { + $all = $this->types->all(); + + $this->assertSame(['header', 'text', 'data'], array_keys($all)); + foreach ($all as $name => $type) { + $this->assertSame($name, $type['type']); + $this->assertSame($name === 'data', $type['hasSource']); + } + $this->assertSame(['heading', 'subheading', 'textAlign', 'backgroundColor', 'textColor'], array_keys($all['header']['settings'])); + $this->assertSame([12, 5], [$all['data']['defaultWidth'], $all['data']['defaultHeight']]); + $this->assertSame([], $all['data']['settings']); + } + + public function testTitlesAreTranslated(): void { + $header = $this->types->get('header'); + + $this->assertSame('[Header]', $header['title']); + $this->assertSame('[Heading]', $header['settings']['heading']['title']); + $this->assertSame('[Centered]', $header['settings']['textAlign']['options'][1]['label']); + $this->assertSame('[Description]', $this->types->get('text')['title']); + } + + public function testUnknownTypeIsRejected(): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Unknown widget type "chart"'); + $this->types->get('chart'); + } + + public function testSanitizeFillsDefaultsAndDropsUnknownSettings(): void { + $this->assertSame( + ['heading' => 'Welcome', 'subheading' => '', 'textAlign' => 'center', 'backgroundColor' => '#fff', 'textColor' => ''], + $this->types->sanitizeSettings('header', ['heading' => 'Welcome', 'textAlign' => 'center', 'backgroundColor' => '#fff', 'script' => '']), + ); + $this->assertSame(['text' => ''], $this->types->sanitizeSettings('text', null)); + $this->assertSame([], $this->types->sanitizeSettings('data', ['pageSize' => 10])); + } + + public static function invalidSettings(): array { + return [ + 'settings not an object' => ['text', 'x', 'settings of a text widget must be an object'], + 'required heading missing' => ['header', ['heading' => ' '], 'header.heading is required'], + 'text not a string' => ['text', ['text' => 3], 'text.text must be a string'], + 'color not hex' => ['header', ['heading' => 'A', 'textColor' => 'red'], 'header.textColor must be a hex color'], + 'color of four digits' => ['header', ['heading' => 'A', 'textColor' => '#abcd'], 'header.textColor must be a hex color'], + 'color of five digits' => ['header', ['heading' => 'A', 'textColor' => '#abcde'], 'header.textColor must be a hex color'], + 'settings too large' => ['text', ['text' => str_repeat('a', 60000)], 'take more than 60000 bytes'], + 'text not utf-8' => ['text', ['text' => "\xff"], 'must be valid UTF-8 text'], + 'color with a trailing newline' => ['header', ['heading' => 'A', 'textColor' => "#abc\n"], 'header.textColor must be a hex color'], + 'alignment unknown' => ['header', ['heading' => 'A', 'textAlign' => 'justify'], 'header.textAlign must be one of'], + ]; + } + + /** + * @dataProvider invalidSettings + */ + public function testSanitizeRejectsInvalidSettings(string $type, mixed $settings, string $message): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->types->sanitizeSettings($type, $settings); + } + + public function testAPositionOnTheGridIsKept(): void { + $this->assertSame( + ['gridX' => 6, 'gridY' => 3, 'gridWidth' => 6, 'gridHeight' => 100], + $this->types->validatePosition(0, ['gridX' => 6, 'gridY' => 3, 'gridWidth' => 6, 'gridHeight' => 100, 'other' => 1]), + ); + } + + public static function invalidPositions(): array { + $at = ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]; + return [ + 'missing' => [['gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2], 'needs gridX'], + 'not a whole number' => [['gridX' => '1'] + $at, 'needs gridX'], + 'below the grid' => [['gridY' => -1] + $at, 'needs gridY'], + 'too far down' => [['gridY' => 10001] + $at, 'needs gridY'], + 'no width' => [['gridWidth' => 0] + $at, 'needs gridWidth'], + 'too high' => [['gridHeight' => 101] + $at, 'needs gridHeight'], + 'past the last column' => [['gridX' => 7] + $at, 'does not fit in 12 columns'], + ]; + } + + /** + * @dataProvider invalidPositions + */ + public function testAPositionOffTheGridIsRefused(array $widget, string $message): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Widget 3 ' . $message); + $this->types->validatePosition(3, $widget); + } + + public function testATitleIsTrimmedAndAnEmptyOneIsNone(): void { + $this->assertSame('Notes', $this->types->validateTitle(0, ' Notes ')); + $this->assertNull($this->types->validateTitle(0, ' ')); + $this->assertNull($this->types->validateTitle(0, null)); + $this->assertSame(str_repeat('a', 200), $this->types->validateTitle(0, str_repeat('a', 200))); + } + + public function testATitleThatIsTooLongOrNoTextIsRefused(): void { + foreach ([str_repeat('a', 201), 5, ['a']] as $title) { + try { + $this->types->validateTitle(2, $title); + $this->fail('Accepted ' . json_encode($title)); + } catch (BadRequestError $e) { + $this->assertStringContainsString('The title of widget 2', $e->getMessage()); + } + } + } + + public function testShortAndLongHexColorsAreAccepted(): void { + $settings = $this->types->sanitizeSettings('header', ['heading' => 'A', 'textColor' => '#abc', 'backgroundColor' => '#A0b1C2']); + $this->assertSame(['#abc', '#A0b1C2'], [$settings['textColor'], $settings['backgroundColor']]); + } + + public function testADescriptionIsTrimmedAndAnEmptyOneIsNone(): void { + $this->assertSame('Open tasks', $this->types->validateDescription(0, ' Open tasks ')); + $this->assertNull($this->types->validateDescription(0, ' ')); + $this->assertNull($this->types->validateDescription(0, null)); + $this->assertSame(str_repeat('a', 2000), $this->types->validateDescription(0, str_repeat('a', 2000))); + } + + public function testADescriptionThatIsTooLongOrNoTextIsRefused(): void { + foreach ([str_repeat('a', 2001), 5, ['a']] as $description) { + try { + $this->types->validateDescription(3, $description); + $this->fail('Accepted ' . json_encode($description)); + } catch (BadRequestError $e) { + $this->assertStringContainsString('The description of widget 3', $e->getMessage()); + } + } + } + + public function testSettingsUpToTheLimitAreKept(): void { + // {"text":"…"} adds 11 bytes around the text + $text = str_repeat('a', GridWidgetTypes::SETTINGS_MAX_BYTES - 11); + $this->assertSame(['text' => $text], $this->types->sanitizeSettings('text', ['text' => $text])); + } +} diff --git a/tests/unit/Service/WidgetServiceTest.php b/tests/unit/Service/WidgetServiceTest.php new file mode 100644 index 0000000000..80d94847d3 --- /dev/null +++ b/tests/unit/Service/WidgetServiceTest.php @@ -0,0 +1,272 @@ + node relation ids of the application, by the number a test names them by */ + private array $relations = []; + + private WidgetService $service; + + protected function setUp(): void { + parent::setUp(); + $this->service = new WidgetService( + new WidgetMapper($this->connectionAdapter), + new WidgetSourceMapper($this->connectionAdapter), + ); + $this->page = $this->insertRow('tables_contexts_page', ['context_id' => self::CONTEXT, 'page_type' => 'grid', 'technical_name' => 'one']); + $this->otherPage = $this->insertRow('tables_contexts_page', ['context_id' => self::CONTEXT, 'page_type' => 'grid', 'technical_name' => 'two']); + } + + protected function tearDown(): void { + $this->service->deleteByPageIds([$this->page, $this->otherPage]); + foreach (['tables_contexts_page', 'tables_contexts_rel_context_node'] as $table) { + $qb = $this->connectionAdapter->getQueryBuilder(); + $qb->delete($table) + ->where($qb->expr()->in('context_id', $qb->createNamedParameter([self::CONTEXT, self::OTHER_CONTEXT], IQueryBuilder::PARAM_INT_ARRAY))) + ->executeStatement(); + } + parent::tearDown(); + } + + /** + * A node relation of the application of the test pages. + */ + private function rel(int $number): int { + return $this->relations[$number] ??= $this->insertRow('tables_contexts_rel_context_node', ['context_id' => self::CONTEXT, 'node_id' => $number, 'node_type' => 0, 'permissions' => 1]); + } + + /** + * @param array $values + */ + private function insertRow(string $table, array $values): int { + $qb = $this->connectionAdapter->getQueryBuilder(); + $qb->insert($table) + ->values(array_map(static fn (int|string $value) => $qb->createNamedParameter($value, is_int($value) ? IQueryBuilder::PARAM_INT : IQueryBuilder::PARAM_STR), $values)) + ->executeStatement(); + return $qb->getLastInsertId(); + } + + public function testASourceOfAnotherApplicationIsRefusedOnInsertAndSync(): void { + $foreign = $this->insertRow('tables_contexts_rel_context_node', ['context_id' => self::OTHER_CONTEXT, 'node_id' => 1, 'node_type' => 0, 'permissions' => 1]); + $widget = $this->service->insert($this->page, 'data', null, $this->position(0, 0, 6, 5), [], $this->rel(1)); + + try { + $this->service->insert($this->page, 'data', null, $this->position(6, 0, 6, 5), [], $foreign); + $this->fail('An insert accepted a node of another application.'); + } catch (\InvalidArgumentException) { + } + try { + $this->service->syncPage($this->page, [ + ['id' => $widget->getId(), 'type' => 'data', 'title' => null, 'position' => $this->position(0, 0, 6, 5), 'settings' => [], 'nodeRelId' => $foreign], + ]); + $this->fail('A sync accepted a node of another application.'); + } catch (\InvalidArgumentException) { + } + + $widgets = $this->service->findByPageIds([$this->page])[$this->page]; + $this->assertSame([['nodeRelId' => $this->rel(1)]], array_column($widgets, 'source')); + } + + public function testInsertedWidgetsAreReadBackWithSettingsAndSource(): void { + $data = $this->service->insert($this->page, 'data', 'Tasks', $this->position(0, 4, 12, 5), [], $this->rel(31), 'star', 'Open tasks'); + $header = $this->service->insert($this->page, 'header', null, $this->position(0, 0, 12, 2), [ + 'text' => 'Projects', + 'showTitle' => false, + 'level' => 2, + 'color' => null, + ], null); + $this->service->insert($this->otherPage, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'Other'], null); + + $byPage = $this->service->findByPageIds([$this->page]); + + $this->assertSame([$this->page], array_keys($byPage)); + $this->assertSame(json_encode([ + [ + 'id' => $header->getId(), + 'type' => 'header', + 'title' => null, + 'icon' => null, + 'description' => null, + 'gridX' => 0, + 'gridY' => 0, + 'gridWidth' => 12, + 'gridHeight' => 2, + 'settings' => (object)['text' => 'Projects', 'showTitle' => false, 'level' => 2, 'color' => null], + 'source' => null, + ], + [ + 'id' => $data->getId(), + 'type' => 'data', + 'title' => 'Tasks', + 'icon' => 'star', + 'description' => 'Open tasks', + 'gridX' => 0, + 'gridY' => 4, + 'gridWidth' => 12, + 'gridHeight' => 5, + 'settings' => new \stdClass(), + 'source' => ['nodeRelId' => $this->rel(31)], + ], + ]), json_encode($byPage[$this->page])); + } + + public function testRemovingANodeKeepsTheWidgetWithoutSource(): void { + $kept = $this->service->insert($this->page, 'data', null, $this->position(0, 0, 6, 5), [], $this->rel(41)); + $unlinked = $this->service->insert($this->page, 'data', null, $this->position(6, 0, 6, 5), ['pageSize' => 10], $this->rel(42)); + + $this->service->deleteSourcesByNodeRelIds([$this->rel(42)]); + + $widgets = $this->service->findByPageIds([$this->page])[$this->page]; + $this->assertSame([$kept->getId(), $unlinked->getId()], array_column($widgets, 'id')); + $this->assertSame(['nodeRelId' => $this->rel(41)], $widgets[0]['source']); + $this->assertNull($widgets[1]['source']); + $this->assertSame(['pageSize' => 10], (array)$widgets[1]['settings']); + } + + public function testDeletingPagesDeletesWidgetsSettingsAndSources(): void { + $widget = $this->service->insert($this->page, 'data', null, $this->position(0, 0, 6, 5), ['pageSize' => 10], $this->rel(51)); + $other = $this->service->insert($this->otherPage, 'data', null, $this->position(0, 0, 6, 5), ['pageSize' => 20], $this->rel(52)); + + $this->service->deleteByPageIds([$this->page]); + + $this->assertSame([], $this->service->findByPageIds([$this->page])); + $this->assertNull($this->storedSettings($widget->getId())); + $this->assertSame([], (new WidgetSourceMapper($this->connectionAdapter))->findNodeRelIdsByWidgetIds([$widget->getId()])); + $this->assertSame(['nodeRelId' => $this->rel(52)], $this->service->findByPageIds([$this->otherPage])[$this->otherPage][0]['source']); + $this->assertSame(['pageSize' => 20], (array)$this->service->findByPageIds([$this->otherPage])[$this->otherPage][0]['settings']); + $this->assertSame('{"pageSize":20}', $this->storedSettings($other->getId())); + } + + public function testSettingsAreStoredAsOneJsonObjectOnTheWidgetRow(): void { + $header = $this->service->insert($this->page, 'header', null, $this->position(0, 0, 12, 2), ['text' => 'A', 'level' => 2, 'showTitle' => false, 'color' => null], null); + $data = $this->service->insert($this->page, 'data', null, $this->position(0, 2, 12, 5), [], $this->rel(61)); + + $this->assertSame('{"text":"A","level":2,"showTitle":false,"color":null}', $this->storedSettings($header->getId())); + $this->assertSame('{}', $this->storedSettings($data->getId())); + $this->assertSame([], (array)$this->service->findByPageIds([$this->page])[$this->page][1]['settings']); + } + + private function storedSettings(int $widgetId): ?string { + $qb = $this->connectionAdapter->getQueryBuilder(); + $qb->select('settings') + ->from('tables_contexts_widget') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($widgetId, IQueryBuilder::PARAM_INT))); + $result = $qb->executeQuery(); + $value = $result->fetchOne(); + $result->closeCursor(); + return $value === false || $value === null ? null : (string)$value; + } + + public function testSyncUpdatesListedInsertsNewAndDeletesMissingWidgets(): void { + $kept = $this->service->insert($this->page, 'header', null, $this->position(0, 0, 12, 2), ['heading' => 'Old', 'subheading' => 'Gone'], null); + $removed = $this->service->insert($this->page, 'data', null, $this->position(0, 2, 12, 5), [], $this->rel(61)); + $other = $this->service->insert($this->otherPage, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'Stays'], null); + + $this->service->syncPage($this->page, [ + ['id' => $kept->getId(), 'type' => 'header', 'title' => 'Intro', 'position' => $this->position(0, 0, 6, 3), 'settings' => ['heading' => 'New'], 'nodeRelId' => null], + ['id' => null, 'type' => 'data', 'title' => null, 'position' => $this->position(6, 0, 6, 5), 'settings' => [], 'nodeRelId' => $this->rel(62)], + ]); + + $widgets = $this->service->findByPageIds([$this->page, $this->otherPage]); + $this->assertCount(2, $widgets[$this->page]); + [$header, $data] = $widgets[$this->page]; + $this->assertSame($kept->getId(), $header['id']); + $this->assertSame(['Intro', 0, 0, 6, 3], [$header['title'], $header['gridX'], $header['gridY'], $header['gridWidth'], $header['gridHeight']]); + $this->assertSame(['heading' => 'New'], (array)$header['settings']); + $this->assertNotSame($removed->getId(), $data['id']); + $this->assertSame(['nodeRelId' => $this->rel(62)], $data['source']); + $this->assertSame([], (new WidgetSourceMapper($this->connectionAdapter))->findNodeRelIdsByWidgetIds([$removed->getId()])); + $this->assertSame($other->getId(), $widgets[$this->otherPage][0]['id']); + } + + public function testSyncWithNoSettingsClearsTheStoredSettings(): void { + $widget = $this->service->insert($this->page, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'A'], null); + + $this->service->syncPage($this->page, [ + ['id' => $widget->getId(), 'type' => 'text', 'title' => null, 'position' => $this->position(0, 0, 6, 2), 'settings' => [], 'nodeRelId' => null], + ]); + + $this->assertSame([], (array)$this->service->findByPageIds([$this->page])[$this->page][0]['settings']); + $this->assertSame('{}', $this->storedSettings($widget->getId())); + } + + public function testSyncWithNoWidgetsEmptiesThePage(): void { + $this->service->insert($this->page, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'A'], null); + + $this->service->syncPage($this->page, []); + + $this->assertSame([], $this->service->findByPageIds([$this->page])); + } + + public function testSyncClearsATitleBackToNull(): void { + $widget = $this->service->insert($this->page, 'text', 'Notes', $this->position(0, 0, 6, 2), ['text' => 'A'], null); + + $this->service->syncPage($this->page, [ + ['id' => $widget->getId(), 'type' => 'text', 'title' => null, 'position' => $this->position(0, 0, 6, 2), 'settings' => ['text' => 'A'], 'nodeRelId' => null], + ]); + + $this->assertNull($this->service->findByPageIds([$this->page])[$this->page][0]['title']); + } + + public function testSyncWritesAndClearsTheIconAndDescription(): void { + $widget = $this->service->insert($this->page, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'A'], null, 'star', 'Before'); + + $this->service->syncPage($this->page, [ + ['id' => $widget->getId(), 'type' => 'text', 'title' => null, 'icon' => 'home', 'description' => 'After', 'position' => $this->position(0, 0, 6, 2), 'settings' => ['text' => 'A'], 'nodeRelId' => null], + ['id' => null, 'type' => 'text', 'title' => null, 'icon' => 'star', 'description' => 'New', 'position' => $this->position(6, 0, 6, 2), 'settings' => ['text' => 'B'], 'nodeRelId' => null], + ]); + $widgets = $this->service->findByPageIds([$this->page])[$this->page]; + $this->assertSame([['home', 'After'], ['star', 'New']], array_map(static fn (array $widget): array => [$widget['icon'], $widget['description']], $widgets)); + + $this->service->syncPage($this->page, [ + ['id' => $widget->getId(), 'type' => 'text', 'title' => null, 'icon' => null, 'description' => null, 'position' => $this->position(0, 0, 6, 2), 'settings' => ['text' => 'A'], 'nodeRelId' => null], + ]); + $widgets = $this->service->findByPageIds([$this->page])[$this->page]; + $this->assertSame([[null, null]], array_map(static fn (array $widget): array => [$widget['icon'], $widget['description']], $widgets)); + } + + public function testSyncRejectsTheIdOfAWidgetOnAnotherPage(): void { + $own = $this->service->insert($this->page, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'Own'], null); + $other = $this->service->insert($this->otherPage, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'Other'], null); + + try { + $this->service->syncPage($this->page, [ + ['id' => $other->getId(), 'type' => 'text', 'title' => null, 'position' => $this->position(0, 0, 6, 2), 'settings' => ['text' => 'Moved'], 'nodeRelId' => null], + ]); + $this->fail('A widget of another page was accepted.'); + } catch (\InvalidArgumentException) { + } + + $this->assertSame([$own->getId()], array_column($this->service->findByPageIds([$this->page])[$this->page], 'id')); + $this->assertSame(['text' => 'Other'], (array)$this->service->findByPageIds([$this->otherPage])[$this->otherPage][0]['settings']); + } + + /** + * @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);