diff --git a/appinfo/info.xml b/appinfo/info.xml index 8545043c14..8698fe6963 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.2 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Db/Context.php b/lib/Db/Context.php index 3fa14f56b5..1437136fc0 100644 --- a/lib/Db/Context.php +++ b/lib/Db/Context.php @@ -9,6 +9,7 @@ namespace OCA\Tables\Db; use JsonSerializable; +use OCA\Tables\Helper\PageContentHelper; /** * @method getName(): string @@ -63,7 +64,7 @@ public function jsonSerialize(): array { if (is_array($this->sharing) || is_array($this->nodes) || is_array($this->pages)) { $data['sharing'] = $this->getSharing(); $data['nodes'] = $this->getNodes(); - $data['pages'] = $this->getPages(); + $data['pages'] = is_array($this->pages) ? PageContentHelper::readPages($this->pages) : $this->pages; } return $data; diff --git a/lib/Db/ContextMapper.php b/lib/Db/ContextMapper.php index 0da199335e..55a2b6c1a4 100644 --- a/lib/Db/ContextMapper.php +++ b/lib/Db/ContextMapper.php @@ -36,9 +36,9 @@ 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', - 'pc.id as content_id', 'pc.order', + 'p.id as page_id', 'p.page_type', 'p.title as page_title', 'p.technical_name as page_technical_name', + 'pc.id as content_id', 'pc.order', 'pc.node_rel_id as content_node_rel_id', 'pc.type as content_type', + 'pc.configuration as content_configuration', 'pc.grid_x', 'pc.grid_y', 'pc.grid_width', 'pc.grid_height', 'n.display_mode as display_mode_default', 's.id as share_id', 's.receiver', 's.receiver_type' ) @@ -60,23 +60,80 @@ protected function getFindContextBaseQuery(?string $userId): IQueryBuilder { $qb->leftJoin('c', 'tables_contexts_page', 'p', $qb->expr()->eq('c.id', 'p.context_id')); $qb->leftJoin('p', 'tables_contexts_page_content', 'pc', $qb->expr()->eq('p.id', 'pc.page_id')); - $qb->leftJoin('c', 'tables_contexts_rel_context_node', 'r', $qb->expr()->eq('c.id', 'r.context_id')); $qb->leftJoin('s', 'tables_contexts_navigation', 'n', $qb->expr()->andX( $qb->expr()->eq('s.id', 'n.share_id'), $qb->expr()->eq('n.user_id', $qb->createNamedParameter('')), )); - $qb->andWhere($qb->expr()->orX( - $qb->expr()->eq('pc.node_rel_id', 'r.id'), - $qb->expr()->isNull('pc.node_rel_id'), - )); - - $qb->orderBy('pc.order', 'ASC'); + $qb->orderBy('p.id', 'ASC') + ->addOrderBy('pc.order', 'ASC') + ->addOrderBy('pc.id', 'ASC'); return $qb; } - protected function formatResultRows(array $rows, ?string $userId) { + /** + * The node relations of the given contexts, by context id and relation id. + * With a node type and id, only the relations to that node are returned. + * + * @param int[] $contextIds + * @return array> + * @throws Exception + */ + protected function findNodesForContexts(array $contextIds, ?int $nodeType = null, ?int $nodeId = null): array { + $nodes = []; + if ($contextIds === []) { + return $nodes; + } + $qb = $this->db->getQueryBuilder(); + $qb->select('id', 'context_id', 'node_id', 'node_type', 'permissions') + ->from('tables_contexts_rel_context_node') + ->where($qb->expr()->in('context_id', $qb->createParameter('contextIds'))) + ->orderBy('id', 'ASC'); + if ($nodeType !== null && $nodeId !== null) { + $qb->andWhere($qb->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))); + } + foreach (array_chunk($contextIds, self::DB_CHUNK_SIZE) as $chunk) { + $qb->setParameter('contextIds', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + $result = $qb->executeQuery(); + while ($row = $result->fetchAssociative()) { + $nodes[(int)$row['context_id']][(int)$row['id']] = [ + 'id' => (int)$row['id'], + 'node_id' => (int)$row['node_id'], + 'node_type' => (int)$row['node_type'], + 'permissions' => (int)$row['permissions'], + ]; + } + $result->closeCursor(); + } + return $nodes; + } + + /** + * Groups the rows of the base query by context and formats each context. + * + * @return Context[] + * @throws Exception + */ + protected function formatContexts(array $rows, ?string $userId, ?int $nodeType = null, ?int $nodeId = null): array { + $rowsByContext = []; + foreach ($rows as $row) { + $rowsByContext[(int)$row['id']][] = $row; + } + $nodes = $this->findNodesForContexts(array_keys($rowsByContext), $nodeType, $nodeId); + + $resultEntities = []; + foreach ($rowsByContext as $contextId => $contextRows) { + $resultEntities[] = $this->formatResultRows($contextRows, $userId, $nodes[$contextId] ?? []); + } + return $resultEntities; + } + + /** + * @param array $nodes + */ + protected function formatResultRows(array $rows, ?string $userId, array $nodes) { $formatted = [ 'id' => $rows[0]['id'], 'name' => $rows[0]['name'], @@ -109,19 +166,7 @@ protected function formatResultRows(array $rows, ?string $userId) { return $carry; }, []); - $formatted['nodes'] = array_reduce($rows, function (array $carry, array $item) { - if ($item['node_rel_id'] === null) { - // empty Context - return $carry; - } - $carry[$item['node_rel_id']] = [ - 'id' => (int)$item['node_rel_id'], - 'node_id' => (int)$item['node_id'], - 'node_type' => (int)$item['node_type'], - 'permissions' => (int)$item['permissions'], - ]; - return $carry; - }, []); + $formatted['nodes'] = $nodes; $formatted['pages'] = array_reduce($rows, function (array $carry, array $item) { if ($item['page_id'] === null) { @@ -131,10 +176,19 @@ 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']]['technical_name'] = $item['page_technical_name']; + if ($item['content_id'] !== null) { $carry[$item['page_id']]['content'][$item['content_id']] = [ + 'id' => (int)$item['content_id'], 'order' => (int)$item['order'], - 'node_rel_id' => (int)$item['node_rel_id'] + 'node_rel_id' => $item['content_node_rel_id'] === null ? null : (int)$item['content_node_rel_id'], + 'type' => $item['content_type'], + 'configuration' => $item['content_configuration'], + 'grid_x' => $item['grid_x'] === null ? null : (int)$item['grid_x'], + 'grid_y' => $item['grid_y'] === null ? null : (int)$item['grid_y'], + 'grid_width' => $item['grid_width'] === null ? null : (int)$item['grid_width'], + 'grid_height' => $item['grid_height'] === null ? null : (int)$item['grid_height'], ]; } @@ -154,25 +208,7 @@ public function findAll(?string $userId = null): array { $result = $qb->executeQuery(); $r = $result->fetchAllAssociative(); - $contextIds = []; - foreach ($r as $row) { - $contextIds[$row['id']] = 1; - } - $contextIds = array_keys($contextIds); - unset($row); - - $resultEntities = []; - foreach ($contextIds as $contextId) { - $workArray = []; - foreach ($r as $row) { - if ((int)$row['id'] === $contextId) { - $workArray[] = $row; - } - } - $resultEntities[] = $this->formatResultRows($workArray, $userId); - } - - return $resultEntities; + return $this->formatContexts($r, $userId); } public function findForNavBar(string $userId): array { $qb = $this->getFindContextBaseQuery($userId); @@ -204,25 +240,7 @@ public function findForNavBar(string $userId): array { $result = $qb->executeQuery(); $r = $result->fetchAllAssociative(); - $contextIds = []; - foreach ($r as $row) { - $contextIds[$row['id']] = 1; - } - $contextIds = array_keys($contextIds); - unset($row); - - $resultEntities = []; - foreach ($contextIds as $contextId) { - $workArray = []; - foreach ($r as $row) { - if ((int)$row['id'] === $contextId) { - $workArray[] = $row; - } - } - $resultEntities[] = $this->formatResultRows($workArray, $userId); - } - - return $resultEntities; + return $this->formatContexts($r, $userId); } /** @@ -240,7 +258,7 @@ public function findById(int $contextId, ?string $userId = null): Context { throw new NotFoundError('Context does not exist'); } - return $this->formatResultRows($r, $userId); + return $this->formatContexts($r, $userId)[0]; } /** @@ -250,31 +268,17 @@ public function findById(int $contextId, ?string $userId = null): Context { public function findAllContainingNode(int $nodeType, int $nodeId, string $userId): array { $qb = $this->getFindContextBaseQuery($userId); - $qb->andWhere($qb->expr()->eq('r.node_id', $qb->createNamedParameter($nodeId))) - ->andWhere($qb->expr()->eq('r.node_type', $qb->createNamedParameter($nodeType))); + $containing = $this->db->getQueryBuilder(); + $containing->select('context_id') + ->from('tables_contexts_rel_context_node') + ->where($containing->expr()->eq('node_id', $qb->createNamedParameter($nodeId, IQueryBuilder::PARAM_INT))) + ->andWhere($containing->expr()->eq('node_type', $qb->createNamedParameter($nodeType, IQueryBuilder::PARAM_INT))); + $qb->andWhere($qb->expr()->in('c.id', $qb->createFunction($containing->getSQL()))); $result = $qb->executeQuery(); $r = $result->fetchAllAssociative(); - $contextIds = []; - foreach ($r as $row) { - $contextIds[$row['id']] = 1; - } - $contextIds = array_keys($contextIds); - unset($row); - - $resultEntities = []; - foreach ($contextIds as $contextId) { - $workArray = []; - foreach ($r as $row) { - if ($row['id'] === $contextId) { - $workArray[] = $row; - } - } - $resultEntities[] = $this->formatResultRows($workArray, $userId); - } - - return $resultEntities; + return $this->formatContexts($r, $userId, $nodeType, $nodeId); } /** diff --git a/lib/Db/Page.php b/lib/Db/Page.php index 5ceeb1bd00..5ca1e88b86 100644 --- a/lib/Db/Page.php +++ b/lib/Db/Page.php @@ -8,6 +8,7 @@ namespace OCA\Tables\Db; +use OCA\Tables\Helper\TechnicalNameHelper; use OCP\AppFramework\Db\Entity; /** @@ -15,22 +16,37 @@ * @method setContextId(int $value): void * @method getPageType(): string * @method setPageType(string $value): void + * @method getTitle(): ?string + * @method setTitle(?string $value): void + * @method getTechnicalName(): ?string + * @method setTechnicalName(?string $value): void */ class Page extends Entity implements \JsonSerializable { public const TYPE_STARTPAGE = 'startpage'; + public const TYPE_GRID = 'grid'; + public const TECHNICAL_NAME_PATTERN = TechnicalNameHelper::PATTERN; + public const TECHNICAL_NAME_MAX_LENGTH = TechnicalNameHelper::MAX_LENGTH; protected ?int $contextId = null; protected ?string $pageType = null; + protected ?string $title = null; + protected ?string $technicalName = null; public function __construct() { $this->addType('id', 'integer'); } + public static function isValidTechnicalName(string $technicalName): bool { + return TechnicalNameHelper::isValid($technicalName); + } + public function jsonSerialize(): array { return [ 'id' => $this->getId(), 'contextId' => $this->getContextId(), 'pageType' => $this->getPageType(), + 'title' => $this->getTitle(), + 'technicalName' => $this->getTechnicalName(), ]; } } diff --git a/lib/Db/PageContent.php b/lib/Db/PageContent.php index 966f48fc53..da61971a42 100644 --- a/lib/Db/PageContent.php +++ b/lib/Db/PageContent.php @@ -13,21 +13,43 @@ /** * @method getPageId(): int * @method setPageId(int $value): void - * @method getNodeRelId(): int - * @method setNodeRelId(int $value): void + * @method getNodeRelId(): ?int + * @method setNodeRelId(?int $value): void * @method getOrder(): int * @method setOrder(int $value): void + * @method getType(): string + * @method setType(string $value): void + * @method getConfiguration(): ?string + * @method setConfiguration(?string $value): void + * @method getGridX(): ?int + * @method setGridX(?int $value): void + * @method getGridY(): ?int + * @method setGridY(?int $value): void + * @method getGridWidth(): ?int + * @method setGridWidth(?int $value): void + * @method getGridHeight(): ?int + * @method setGridHeight(?int $value): void */ class PageContent extends Entity implements \JsonSerializable { protected ?int $pageId = null; protected ?int $nodeRelId = null; protected ?int $order = null; + protected ?string $type = null; + protected ?string $configuration = null; + protected ?int $gridX = null; + protected ?int $gridY = null; + protected ?int $gridWidth = null; + protected ?int $gridHeight = null; public function __construct() { $this->addType('id', 'integer'); $this->addType('page_id', 'integer'); $this->addType('node_rel_id', 'integer'); $this->addType('order', '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 { @@ -36,6 +58,12 @@ public function jsonSerialize(): array { 'pageId' => $this->getPageId(), 'nodeRelId' => $this->getNodeRelId(), 'order' => $this->getOrder(), + 'type' => $this->getType(), + 'configuration' => $this->getConfiguration(), + 'gridX' => $this->getGridX(), + 'gridY' => $this->getGridY(), + 'gridWidth' => $this->getGridWidth(), + 'gridHeight' => $this->getGridHeight(), ]; } } diff --git a/lib/Helper/PageContentHelper.php b/lib/Helper/PageContentHelper.php new file mode 100644 index 0000000000..13a1eba06a --- /dev/null +++ b/lib/Helper/PageContentHelper.php @@ -0,0 +1,151 @@ +> $pages + * @return array> + */ + public static function readPages(array $pages): array { + foreach ($pages as $key => $page) { + if (is_array($page['content'] ?? null)) { + $pages[$key]['content'] = self::readContent($page['content']); + } + } + return $pages; + } + + /** + * Returns the content of one page, keyed as given, with every entry carrying + * id, order, node_rel_id, type, configuration, content and a grid position. + * Other keys an entry already has are kept. + * + * @param array> $content entries shaped like the context + * mapper output or PageContent::jsonSerialize() + * @return array> + */ + public static function readContent(array $content): array { + $entries = []; + $bottom = 0; + foreach ($content as $key => $entry) { + $entry = self::readEntry($key, $entry); + if (self::hasPosition($entry)) { + $bottom = max($bottom, $entry['grid_y'] + $entry['grid_height']); + } + $entries[$key] = $entry; + } + + $unplaced = array_keys(array_filter($entries, static fn (array $entry): bool => !self::hasPosition($entry))); + usort($unplaced, static fn ($a, $b): int => [$entries[$a]['order'], $entries[$a]['id']] <=> [$entries[$b]['order'], $entries[$b]['id']]); + foreach ($unplaced as $key) { + $entries[$key]['grid_x'] = 0; + $entries[$key]['grid_y'] = $bottom; + $entries[$key]['grid_width'] = self::UNPLACED_WIDTH; + $entries[$key]['grid_height'] = self::UNPLACED_HEIGHT; + $bottom += self::UNPLACED_HEIGHT; + } + + return $entries; + } + + /** + * The order every item gets from its position: top to bottom, then left to right. + * Items keep their keys; the first gets 10, the next 20 and so on. + * + * @param array $items + * @return array + */ + public static function orderFromPositions(array $items): array { + $keys = array_keys($items); + usort($keys, static fn ($a, $b): int => [$items[$a]['grid_y'], $items[$a]['grid_x']] <=> [$items[$b]['grid_y'], $items[$b]['grid_x']]); + $order = []; + foreach ($keys as $index => $key) { + $order[$key] = ($index + 1) * 10; + } + return $order; + } + + /** + * Decodes a stored configuration into its configuration and content, each + * filled up with the defaults of the widget type. + * + * @return array{configuration: array, content: array} + */ + public static function readConfiguration(string $type, mixed $stored): array { + $defaults = GridWidgetTypes::defaults($type); + if (is_string($stored)) { + $stored = json_decode($stored, true); + } + if (!is_array($stored)) { + return $defaults; + } + return [ + 'configuration' => array_merge($defaults['configuration'], is_array($stored['configuration'] ?? null) ? $stored['configuration'] : []), + 'content' => array_merge($defaults['content'], is_array($stored['content'] ?? null) ? $stored['content'] : []), + ]; + } + + /** + * @param array $entry + * @return array + */ + private static function readEntry(int|string $key, array $entry): array { + $type = $entry['type'] ?? null; + $type = is_string($type) && $type !== '' ? $type : GridWidgetTypes::TYPE_DATA; + $nodeRelId = $entry['node_rel_id'] ?? $entry['nodeRelId'] ?? null; + + $entry['id'] = (int)($entry['id'] ?? $key); + $entry['order'] = (int)($entry['order'] ?? 0); + $entry['node_rel_id'] = $nodeRelId === null ? null : (int)$nodeRelId; + $entry['type'] = $type; + $entry = array_merge($entry, self::readConfiguration($type, $entry['configuration'] ?? null)); + + $position = [ + 'grid_x' => $entry['grid_x'] ?? $entry['gridX'] ?? null, + 'grid_y' => $entry['grid_y'] ?? $entry['gridY'] ?? null, + 'grid_width' => $entry['grid_width'] ?? $entry['gridWidth'] ?? null, + 'grid_height' => $entry['grid_height'] ?? $entry['gridHeight'] ?? null, + ]; + unset($entry['gridX'], $entry['gridY'], $entry['gridWidth'], $entry['gridHeight']); + foreach ($position as $name => $value) { + $entry[$name] = $value === null ? null : (int)$value; + } + return $entry; + } + + /** + * @param array $entry + */ + private static function hasPosition(array $entry): bool { + foreach (self::POSITION_KEYS as $name) { + if ($entry[$name] === null) { + return false; + } + } + return true; + } +} diff --git a/lib/Helper/TechnicalNameHelper.php b/lib/Helper/TechnicalNameHelper.php new file mode 100644 index 0000000000..e7e227977a --- /dev/null +++ b/lib/Helper/TechnicalNameHelper.php @@ -0,0 +1,100 @@ + $taken + */ + public static function unique(string $base, array $taken, int $maxLength = self::MAX_LENGTH): string { + $technicalName = $base; + for ($suffix = 2; isset($taken[$technicalName]); $suffix++) { + $ending = '_' . $suffix; + $technicalName = rtrim(substr($base, 0, $maxLength - strlen($ending)), '_') . $ending; + } + return $technicalName; + } + + /** + * Names a set of pages within one application. Pages whose given name is free + * keep it, in the order given; every other page gets a unique name made from its + * given name, else its fallback. + * + * @param array $pages + * @param array $taken names already used in the application + * @return array the name of every page, by its key + */ + public static function assign(array $pages, array $taken = []): array { + $names = []; + foreach ($pages as $key => $page) { + $given = $page['name']; + if ($given !== null && $given !== '' && self::isValid($given) && !isset($taken[$given])) { + $names[$key] = $given; + $taken[$given] = true; + } + } + foreach ($pages as $key => $page) { + if (isset($names[$key])) { + continue; + } + $given = $page['name']; + $base = $given !== null && $given !== '' ? self::fromText($given) : $page['fallback']; + $names[$key] = self::unique($base, $taken); + $taken[$names[$key]] = true; + } + $ordered = []; + foreach (array_keys($pages) as $key) { + $ordered[$key] = $names[$key]; + } + return $ordered; + } + + /** + * The name a page gets when none is given: `dashboard` for a start page, else + * one made from its title. + */ + public static function pageFallback(bool $isStartPage, ?string $title): string { + if ($isStartPage) { + return self::START_PAGE_NAME; + } + return self::fromText($title ?? ''); + } + + public static function isValid(string $technicalName, int $maxLength = self::MAX_LENGTH): bool { + return strlen($technicalName) <= $maxLength + && preg_match(self::PATTERN, $technicalName) === 1; + } +} diff --git a/lib/Migration/Version2400Date20261015000001.php b/lib/Migration/Version2400Date20261015000001.php new file mode 100644 index 0000000000..533fd6f7c9 --- /dev/null +++ b/lib/Migration/Version2400Date20261015000001.php @@ -0,0 +1,77 @@ +hasTable('tables_contexts_page')) { + $table = $schema->getTable('tables_contexts_page'); + foreach (['title', 'technical_name'] as $column) { + if (!$table->hasColumn($column)) { + $table->addColumn($column, Types::STRING, [ + 'notnull' => false, + 'length' => 200, + ]); + $changed = true; + } + } + } + + if ($schema->hasTable('tables_contexts_page_content')) { + $table = $schema->getTable('tables_contexts_page_content'); + if (!$table->hasColumn('type')) { + $table->addColumn('type', Types::STRING, [ + 'notnull' => true, + 'length' => 32, + 'default' => 'data', + ]); + $changed = true; + } + if (!$table->hasColumn('configuration')) { + $table->addColumn('configuration', Types::TEXT, [ + 'notnull' => false, + ]); + $changed = true; + } + foreach (['grid_x', 'grid_y', 'grid_width', 'grid_height'] as $column) { + if (!$table->hasColumn($column)) { + $table->addColumn($column, Types::INTEGER, [ + 'notnull' => false, + ]); + $changed = true; + } + } + $nodeRelId = $table->getColumn('node_rel_id'); + if ($nodeRelId->getNotnull()) { + $nodeRelId->setNotnull(false); + $changed = true; + } + } + + return $changed ? $schema : null; + } +} diff --git a/lib/Migration/Version2400Date20261015000002.php b/lib/Migration/Version2400Date20261015000002.php new file mode 100644 index 0000000000..cbda1ccb09 --- /dev/null +++ b/lib/Migration/Version2400Date20261015000002.php @@ -0,0 +1,104 @@ +`. Valid names that are free keep their value. + */ +class Version2400Date20261015000002 extends SimpleMigrationStep { + private const TABLE = 'tables_contexts_page'; + + public function __construct( + private readonly IDBConnection $db, + ) { + } + + /** + * @param Closure(): ISchemaWrapper $schemaClosure + */ + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $select = $this->db->getQueryBuilder(); + $select->select('id', 'context_id', 'page_type', 'title', 'technical_name') + ->from(self::TABLE) + ->orderBy('context_id') + ->addOrderBy('id'); + $result = $select->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + $names = self::backfillNames($rows); + if ($names === []) { + return; + } + + $update = $this->db->getQueryBuilder(); + $update->update(self::TABLE) + ->set('technical_name', $update->createParameter('technicalName')) + ->where($update->expr()->eq('id', $update->createParameter('pageId'))); + + $this->db->beginTransaction(); + try { + foreach ($names as $pageId => $technicalName) { + $update->setParameter('technicalName', $technicalName); + $update->setParameter('pageId', $pageId, IQueryBuilder::PARAM_INT); + $update->executeStatement(); + } + $this->db->commit(); + } catch (\Exception $e) { + $this->db->rollBack(); + throw $e; + } + } + + /** + * The new technical name of every page row that needs one, by page id. + * + * @param list> $rows page rows ordered by id + * @return array + */ + public static function backfillNames(array $rows): array { + $pagesByContext = []; + foreach ($rows as $row) { + $pageId = (int)$row['id']; + $title = is_string($row['title'] ?? null) ? $row['title'] : ''; + $isStartPage = $row['page_type'] === Page::TYPE_STARTPAGE; + $fallback = $isStartPage || preg_match('/[a-z0-9]/i', $title) === 1 + ? TechnicalNameHelper::pageFallback($isStartPage, $title) + : 'page_' . $pageId; + $pagesByContext[(int)$row['context_id']][$pageId] = [ + 'name' => is_string($row['technical_name'] ?? null) ? $row['technical_name'] : null, + 'fallback' => $fallback, + ]; + } + + $changes = []; + foreach ($pagesByContext as $pages) { + foreach (TechnicalNameHelper::assign($pages) as $pageId => $technicalName) { + if ($pages[$pageId]['name'] !== $technicalName) { + $changes[$pageId] = $technicalName; + } + } + } + return $changes; + } +} diff --git a/lib/Migration/Version2400Date20261015000003.php b/lib/Migration/Version2400Date20261015000003.php new file mode 100644 index 0000000000..b1cad5d367 --- /dev/null +++ b/lib/Migration/Version2400Date20261015000003.php @@ -0,0 +1,47 @@ +hasTable('tables_contexts_page')) { + return null; + } + + $table = $schema->getTable('tables_contexts_page'); + $changed = false; + $technicalName = $table->getColumn('technical_name'); + if (!$technicalName->getNotnull()) { + $technicalName->setNotnull(true); + $changed = true; + } + if (!$table->hasIndex(self::INDEX)) { + $table->addUniqueIndex(['context_id', 'technical_name'], self::INDEX); + $changed = true; + } + + return $changed ? $schema : null; + } +} diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 468d3de83e..18d48b3c06 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -25,6 +25,8 @@ use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; +use OCA\Tables\Helper\PageContentHelper; +use OCA\Tables\Helper\TechnicalNameHelper; use OCA\Tables\Model\ColumnSettings; use OCA\Tables\Model\ContextScheme; use OCA\Tables\Model\FilterSet; @@ -525,6 +527,7 @@ protected function insertPage(Context $context): void { $page = new Page(); $page->setContextId($context->getId()); $page->setPageType(Page::TYPE_STARTPAGE); + $page->setTechnicalName(TechnicalNameHelper::START_PAGE_NAME); $this->pageMapper->insert($page); $addedPage = $page->jsonSerialize(); @@ -631,24 +634,27 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C // insert pages $pageMap = []; if (!empty($context['pages'])) { - foreach ($context['pages'] as $pageArr) { + $technicalNames = TechnicalNameHelper::assign(array_map(static function ($pageArr): array { + $technicalName = $pageArr['technical_name'] ?? $pageArr['technicalName'] ?? null; + $title = $pageArr['title'] ?? null; + $pageType = $pageArr['page_type'] ?? $pageArr['pageType'] ?? null; + return [ + 'name' => is_string($technicalName) ? $technicalName : null, + 'fallback' => TechnicalNameHelper::pageFallback($pageType === Page::TYPE_STARTPAGE, is_string($title) ? $title : null), + ]; + }, $context['pages'])); + foreach ($context['pages'] as $key => $pageArr) { $page = new Page(); $page->setContextId($newContext->getId()); $page->setPageType($pageArr['page_type'] ?? $pageArr['pageType'] ?? ''); + $title = $pageArr['title'] ?? null; + $page->setTitle(is_string($title) ? mb_substr($title, 0, 200) : null); + $page->setTechnicalName($technicalNames[$key]); $page = $this->pageMapper->insert($page); $pageMap[$pageArr['id'] ?? null] = $page; - // insert page conten if (!empty($pageArr['content'])) { - foreach ($pageArr['content'] as $contentArr) { - $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->setOrder($contentArr['order'] ?? 100); - $this->pageContentMapper->insert($pageContent); - } + $this->importPageContent($page, $pageArr['content'], $nodeRelMap); } } } @@ -656,6 +662,66 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C return $newContext; } + /** + * Inserts the content of an imported page. Content whose node is not part of + * the import keeps its place without a node. When every entry has a grid + * position, the order follows the positions. + * + * @param array $contents + * @param array $nodeRelMap node relations by their exported id + * @throws Exception + */ + protected function importPageContent(Page $page, array $contents, array $nodeRelMap): void { + $pageContents = []; + $positions = []; + foreach ($contents as $key => $contentArr) { + if (!is_array($contentArr)) { + 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->setOrder((int)($contentArr['order'] ?? 100)); + if (is_string($contentArr['type'] ?? null) && $contentArr['type'] !== '') { + $pageContent->setType(mb_substr($contentArr['type'], 0, 32)); + } + $configuration = $contentArr['configuration'] ?? null; + if (is_array($configuration)) { + $configuration = json_encode(['configuration' => $configuration, 'content' => $contentArr['content'] ?? []]); + } + if (is_string($configuration)) { + $pageContent->setConfiguration($configuration); + } + + $position = []; + foreach (['grid_x' => 'gridX', 'grid_y' => 'gridY', 'grid_width' => 'gridWidth', 'grid_height' => 'gridHeight'] as $snake => $camel) { + $value = $contentArr[$snake] ?? $contentArr[$camel] ?? null; + if (is_int($value)) { + $position[$snake] = $value; + } + } + if (count($position) === 4) { + $pageContent->setGridX($position['grid_x']); + $pageContent->setGridY($position['grid_y']); + $pageContent->setGridWidth($position['grid_width']); + $pageContent->setGridHeight($position['grid_height']); + $positions[$key] = $position; + } + $pageContents[$key] = $pageContent; + } + + if ($positions !== [] && count($positions) === count($pageContents)) { + foreach (PageContentHelper::orderFromPositions($positions) as $key => $order) { + $pageContents[$key]->setOrder($order); + } + } + foreach ($pageContents as $pageContent) { + $this->pageContentMapper->insert($pageContent); + } + } + /** * @param int $contextId * diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php new file mode 100644 index 0000000000..cf5e672160 --- /dev/null +++ b/lib/Service/GridWidgetTypes.php @@ -0,0 +1,64 @@ + [ + 'width' => 12, + 'height' => 2, + 'configuration' => ['title' => '', 'showTitle' => false], + 'content' => ['title' => '', 'subtitle' => '', 'textAlign' => 'left', 'backgroundColor' => '', 'textColor' => ''], + ], + self::TYPE_TEXT => [ + 'width' => 6, + 'height' => 2, + 'configuration' => ['title' => '', 'showTitle' => true], + 'content' => ['text' => ''], + ], + self::TYPE_DATA => [ + 'width' => 12, + 'height' => 5, + 'configuration' => ['title' => '', 'showTitle' => false], + 'content' => [], + ], + ]; + + /** + * The configuration and content a widget of the given type starts with. + * An unknown type has none. + * + * @return array{configuration: array, content: array} + */ + public static function defaults(string $type): array { + return [ + 'configuration' => self::DEFAULTS[$type]['configuration'] ?? [], + 'content' => self::DEFAULTS[$type]['content'] ?? [], + ]; + } +} diff --git a/tests/unit/Db/ContextMapperTest.php b/tests/unit/Db/ContextMapperTest.php new file mode 100644 index 0000000000..39b0b5a8f5 --- /dev/null +++ b/tests/unit/Db/ContextMapperTest.php @@ -0,0 +1,233 @@ +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->contextIds as $contextId) { + foreach ($this->pageMapper->getPageIdsForContext($contextId) as $pageId) { + $this->pageContentMapper->deleteByPageId($pageId); + $this->pageMapper->deleteByPageId($pageId); + } + $this->nodeRelMapper->deleteAllByContextId($contextId); + $qb = $this->connection->getQueryBuilder(); + $qb->delete('tables_contexts_context') + ->where($qb->expr()->eq('id', $qb->createNamedParameter($contextId, IQueryBuilder::PARAM_INT))) + ->executeStatement(); + } + parent::tearDown(); + } + + public function testContextWithNodesAndContent(): void { + $context = $this->createContext(); + $table = $this->addNode($context, Application::NODE_TYPE_TABLE, 11, Application::PERMISSION_READ); + $view = $this->addNode($context, Application::NODE_TYPE_VIEW, 12, Application::PERMISSION_ALL); + $page = $this->addPage($context, Page::TYPE_STARTPAGE); + $viewContent = $this->addContent($page, $view->getId(), 10); + $tableContent = $this->addContent($page, $table->getId(), 20); + + $found = $this->mapper->findById($context->getId(), self::OWNER); + + $this->assertSame([ + $table->getId() => ['id' => $table->getId(), 'node_id' => 11, 'node_type' => Application::NODE_TYPE_TABLE, 'permissions' => Application::PERMISSION_READ], + $view->getId() => ['id' => $view->getId(), 'node_id' => 12, 'node_type' => Application::NODE_TYPE_VIEW, 'permissions' => Application::PERMISSION_ALL], + ], $found->getNodes()); + $pages = $found->getPages(); + $this->assertSame([$page->getId()], array_keys($pages)); + $this->assertSame(Page::TYPE_STARTPAGE, $pages[$page->getId()]['page_type']); + $this->assertSame([$viewContent->getId(), $tableContent->getId()], array_keys($pages[$page->getId()]['content'])); + $this->assertSame(10, $pages[$page->getId()]['content'][$viewContent->getId()]['order']); + $this->assertSame($view->getId(), $pages[$page->getId()]['content'][$viewContent->getId()]['node_rel_id']); + $this->assertSame(20, $pages[$page->getId()]['content'][$tableContent->getId()]['order']); + $this->assertSame($table->getId(), $pages[$page->getId()]['content'][$tableContent->getId()]['node_rel_id']); + $this->assertSame('data', $pages[$page->getId()]['content'][$tableContent->getId()]['type']); + } + + public function testContentWithoutNodeMatchesNoNode(): void { + $context = $this->createContext(); + $first = $this->addNode($context, Application::NODE_TYPE_TABLE, 21); + $second = $this->addNode($context, Application::NODE_TYPE_TABLE, 22); + $page = $this->addPage($context, Page::TYPE_GRID); + $header = $this->addContent($page, null, 10, 'header', 0, 0, 12, 2); + $data = $this->addContent($page, $second->getId(), 20, 'data', 0, 2, 12, 5); + + $found = $this->mapper->findById($context->getId(), self::OWNER); + + $this->assertSame([$first->getId(), $second->getId()], array_keys($found->getNodes())); + $content = $found->getPages()[$page->getId()]['content']; + $this->assertSame([$header->getId(), $data->getId()], array_keys($content)); + $this->assertNull($content[$header->getId()]['node_rel_id']); + $this->assertSame('header', $content[$header->getId()]['type']); + $this->assertSame([0, 0, 12, 2], [$content[$header->getId()]['grid_x'], $content[$header->getId()]['grid_y'], $content[$header->getId()]['grid_width'], $content[$header->getId()]['grid_height']]); + $this->assertSame($second->getId(), $content[$data->getId()]['node_rel_id']); + } + + public function testNodeOnNoPageIsStillListed(): void { + $context = $this->createContext(); + $placed = $this->addNode($context, Application::NODE_TYPE_TABLE, 31); + $unplaced = $this->addNode($context, Application::NODE_TYPE_VIEW, 32, Application::PERMISSION_CREATE); + $page = $this->addPage($context, Page::TYPE_STARTPAGE); + $this->addContent($page, $placed->getId(), 10); + + $found = $this->mapper->findById($context->getId(), self::OWNER); + + $this->assertSame([$placed->getId(), $unplaced->getId()], array_keys($found->getNodes())); + $this->assertCount(1, $found->getPages()[$page->getId()]['content']); + + $containing = $this->mapper->findAllContainingNode(Application::NODE_TYPE_VIEW, 32, self::OWNER); + $this->assertCount(1, $containing); + $this->assertSame([$unplaced->getId()], array_keys($containing[0]->getNodes())); + $this->assertSame(Application::PERMISSION_CREATE, $containing[0]->getNodes()[$unplaced->getId()]['permissions']); + } + + public function testEmptyContext(): void { + $context = $this->createContext(); + + $found = $this->mapper->findById($context->getId(), self::OWNER); + + $this->assertSame('Mapper test', $found->getName()); + $this->assertSame([], $found->getNodes()); + $this->assertSame([], $found->getPages()); + $this->assertSame([], $found->getSharing()); + } + + public function testContextWithPagesButNoContent(): void { + $context = $this->createContext(); + $node = $this->addNode($context, Application::NODE_TYPE_TABLE, 41); + $startPage = $this->addPage($context, Page::TYPE_STARTPAGE); + $gridPage = $this->addPage($context, Page::TYPE_GRID, 'Overview', 'overview'); + + $found = $this->mapper->findById($context->getId(), self::OWNER); + + $this->assertSame([$node->getId()], array_keys($found->getNodes())); + $pages = $found->getPages(); + $this->assertSame([$startPage->getId(), $gridPage->getId()], array_keys($pages)); + $this->assertSame([], $pages[$startPage->getId()]['content']); + $this->assertSame([], $pages[$gridPage->getId()]['content']); + $this->assertNull($pages[$startPage->getId()]['title']); + $this->assertSame('Overview', $pages[$gridPage->getId()]['title']); + $this->assertSame('overview', $pages[$gridPage->getId()]['technical_name']); + $this->assertSame('dashboard', $pages[$startPage->getId()]['technical_name']); + } + + public function testTechnicalNameIsUniqueWithinAnApplication(): void { + $context = $this->createContext(); + $other = $this->createContext(); + $this->addPage($context, Page::TYPE_GRID, 'Overview', 'overview'); + $this->addPage($other, Page::TYPE_GRID, 'Overview', 'overview'); + + $this->expectException(\OCP\DB\Exception::class); + $this->addPage($context, Page::TYPE_GRID, 'Overview again', 'overview'); + } + + public function testFindAllReturnsEveryContextOnce(): void { + $withContent = $this->createContext(); + $node = $this->addNode($withContent, Application::NODE_TYPE_TABLE, 51); + $page = $this->addPage($withContent, Page::TYPE_STARTPAGE); + $this->addContent($page, $node->getId(), 10); + $this->addContent($page, null, 20, 'text'); + $empty = $this->createContext(); + + $found = array_values(array_filter( + $this->mapper->findAll(self::OWNER), + fn (Context $context): bool => in_array($context->getId(), [$withContent->getId(), $empty->getId()], true), + )); + + $this->assertSame([$withContent->getId(), $empty->getId()], array_map(fn (Context $context): int => $context->getId(), $found)); + $this->assertSame([$node->getId()], array_keys($found[0]->getNodes())); + $this->assertCount(2, $found[0]->getPages()[$page->getId()]['content']); + $this->assertSame([], $found[1]->getNodes()); + } + + public function testFindAllContainingNodeSkipsContextsWithoutIt(): void { + $context = $this->createContext(); + $this->addNode($context, Application::NODE_TYPE_TABLE, 61); + + $this->assertSame([], $this->mapper->findAllContainingNode(Application::NODE_TYPE_TABLE, 62, self::OWNER)); + } + + 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); + $context = $this->mapper->insert($context); + $this->contextIds[] = $context->getId(); + return $context; + } + + private function addNode(Context $context, int $nodeType, int $nodeId, int $permissions = Application::PERMISSION_READ): ContextNodeRelation { + $relation = new ContextNodeRelation(); + $relation->setContextId($context->getId()); + $relation->setNodeType($nodeType); + $relation->setNodeId($nodeId); + $relation->setPermissions($permissions); + return $this->nodeRelMapper->insert($relation); + } + + private function addPage(Context $context, string $type, ?string $title = null, ?string $technicalName = null): Page { + $page = new Page(); + $page->setContextId($context->getId()); + $page->setPageType($type); + $page->setTitle($title); + $page->setTechnicalName($technicalName ?? ($type === Page::TYPE_STARTPAGE ? 'dashboard' : 'page_' . $type)); + return $this->pageMapper->insert($page); + } + + private function addContent(Page $page, ?int $nodeRelId, int $order, ?string $type = null, ?int $x = null, ?int $y = null, ?int $width = null, ?int $height = null): PageContent { + $content = new PageContent(); + $content->setPageId($page->getId()); + $content->setNodeRelId($nodeRelId); + $content->setOrder($order); + if ($type !== null) { + $content->setType($type); + } + $content->setGridX($x); + $content->setGridY($y); + $content->setGridWidth($width); + $content->setGridHeight($height); + return $this->pageContentMapper->insert($content); + } +} diff --git a/tests/unit/Helper/PageContentHelperTest.php b/tests/unit/Helper/PageContentHelperTest.php new file mode 100644 index 0000000000..703776c6bb --- /dev/null +++ b/tests/unit/Helper/PageContentHelperTest.php @@ -0,0 +1,114 @@ + ['order' => 20, 'node_rel_id' => 2], + 5 => ['order' => 10, 'node_rel_id' => 1], + 9 => ['order' => 30, 'node_rel_id' => 3], + ]); + + $this->assertSame([7, 5, 9], array_keys($content)); + $this->assertSame(['x' => 0, 'y' => 0, 'w' => 12, 'h' => 5], $this->position($content[5])); + $this->assertSame(['x' => 0, 'y' => 5, 'w' => 12, 'h' => 5], $this->position($content[7])); + $this->assertSame(['x' => 0, 'y' => 10, 'w' => 12, 'h' => 5], $this->position($content[9])); + $this->assertSame(5, $content[5]['id']); + $this->assertSame(1, $content[5]['node_rel_id']); + $this->assertSame('data', $content[5]['type']); + $this->assertSame(['title' => '', 'showTitle' => false], $content[5]['configuration']); + $this->assertSame([], $content[5]['content']); + } + + public function testMixedRowsPlaceUnpositionedBelowTheLowestWidget(): void { + $content = PageContentHelper::readContent([ + 1 => ['order' => 10, 'node_rel_id' => null, 'type' => 'header', 'configuration' => '{"configuration":{"title":"Top"},"content":{"title":"Welcome"}}', 'grid_x' => 0, 'grid_y' => 0, 'grid_width' => 12, 'grid_height' => 2], + 2 => ['order' => 20, 'node_rel_id' => 4, 'type' => 'data', 'configuration' => null, 'grid_x' => 6, 'grid_y' => 2, 'grid_width' => 6, 'grid_height' => 8], + 3 => ['order' => 5, 'node_rel_id' => 5, 'type' => null, 'configuration' => null, 'grid_x' => null, 'grid_y' => null, 'grid_width' => null, 'grid_height' => null], + 4 => ['order' => 1, 'node_rel_id' => 6, 'type' => 'data', 'configuration' => null, 'grid_x' => 0, 'grid_y' => null, 'grid_width' => 6, 'grid_height' => 3], + ]); + + $this->assertSame(['x' => 0, 'y' => 0, 'w' => 12, 'h' => 2], $this->position($content[1])); + $this->assertSame(['x' => 6, 'y' => 2, 'w' => 6, 'h' => 8], $this->position($content[2])); + // a partly positioned row counts as unpositioned; both stack by order below y = 10 + $this->assertSame(['x' => 0, 'y' => 10, 'w' => 12, 'h' => 5], $this->position($content[4])); + $this->assertSame(['x' => 0, 'y' => 15, 'w' => 12, 'h' => 5], $this->position($content[3])); + $this->assertSame('header', $content[1]['type']); + $this->assertNull($content[1]['node_rel_id']); + $this->assertSame(['title' => 'Top', 'showTitle' => false], $content[1]['configuration']); + $this->assertSame(['title' => 'Welcome', 'subtitle' => '', 'textAlign' => 'left', 'backgroundColor' => '', 'textColor' => ''], $content[1]['content']); + $this->assertSame('data', $content[3]['type']); + } + + public function testSerializedPageContentIsReadToo(): void { + $content = PageContentHelper::readContent([ + 3 => ['id' => 3, 'pageId' => 1, 'nodeRelId' => 8, 'order' => 100, 'type' => null, 'configuration' => null, 'gridX' => 1, 'gridY' => 2, 'gridWidth' => 3, 'gridHeight' => 4], + ]); + + $this->assertSame(8, $content[3]['node_rel_id']); + $this->assertSame(8, $content[3]['nodeRelId']); + $this->assertSame(1, $content[3]['pageId']); + $this->assertSame(['x' => 1, 'y' => 2, 'w' => 3, 'h' => 4], $this->position($content[3])); + $this->assertArrayNotHasKey('gridX', $content[3]); + } + + public function testUnreadableConfigurationFallsBackToDefaults(): void { + $this->assertSame( + ['configuration' => ['title' => '', 'showTitle' => true], 'content' => ['text' => '']], + PageContentHelper::readConfiguration('text', '{not json'), + ); + $this->assertSame( + ['configuration' => [], 'content' => ['custom' => 1]], + PageContentHelper::readConfiguration('future', ['content' => ['custom' => 1]]), + ); + } + + public function testOrderFollowsPositionTopToBottomThenLeftToRight(): void { + $this->assertSame( + ['c' => 10, 'b' => 20, 'a' => 30, 'd' => 40], + PageContentHelper::orderFromPositions([ + 'a' => ['grid_x' => 0, 'grid_y' => 4], + 'b' => ['grid_x' => 6, 'grid_y' => 0], + 'c' => ['grid_x' => 0, 'grid_y' => 0], + 'd' => ['grid_x' => 6, 'grid_y' => 4], + ]), + ); + } + + public function testContextOutputReadsEveryPage(): void { + $context = new Context(); + $context->setSharing([]); + $context->setNodes([]); + $context->setPages([ + 1 => ['id' => 1, 'page_type' => 'startpage', 'content' => [4 => ['order' => 10, 'node_rel_id' => 2]]], + 2 => ['id' => 2, 'page_type' => 'grid', 'content' => []], + ]); + + $pages = $context->jsonSerialize()['pages']; + + $this->assertSame(['x' => 0, 'y' => 0, 'w' => 12, 'h' => 5], $this->position($pages[1]['content'][4])); + $this->assertSame('data', $pages[1]['content'][4]['type']); + $this->assertSame([], $pages[2]['content']); + // the entity keeps the stored values + $this->assertArrayNotHasKey('type', $context->getPages()[1]['content'][4]); + } + + /** + * @return array{x: mixed, y: mixed, w: mixed, h: mixed} + */ + private function position(array $entry): array { + return ['x' => $entry['grid_x'], 'y' => $entry['grid_y'], 'w' => $entry['grid_width'], 'h' => $entry['grid_height']]; + } +} diff --git a/tests/unit/Helper/TechnicalNameHelperTest.php b/tests/unit/Helper/TechnicalNameHelperTest.php new file mode 100644 index 0000000000..f77b383e8b --- /dev/null +++ b/tests/unit/Helper/TechnicalNameHelperTest.php @@ -0,0 +1,87 @@ + ['Sales', 'sales'], + 'spaces and punctuation' => ['Open cases (2026)!', 'open_cases_2026'], + 'trimmed' => [' --Team overview-- ', 'team_overview'], + 'non-ascii' => ['Café über', 'caf_ber'], + 'leading digit' => ['2026 plan', 'page_2026_plan'], + 'only punctuation' => ['!!!', 'page'], + 'empty' => ['', 'page'], + 'already valid' => ['open_cases_2', 'open_cases_2'], + ]; + } + + /** + * @dataProvider textProvider + */ + public function testFromText(string $text, string $expected): void { + $this->assertSame($expected, TechnicalNameHelper::fromText($text)); + } + + public function testFromTextUsesThePrefix(): void { + $this->assertSame('item', TechnicalNameHelper::fromText('', 'item')); + $this->assertSame('item_1st', TechnicalNameHelper::fromText('1st', 'item')); + } + + public function testFromTextIsCutToTheMaximumLength(): void { + $name = TechnicalNameHelper::fromText(str_repeat('a', 199) . ' b c'); + + $this->assertSame(str_repeat('a', 199), $name); + $this->assertTrue(TechnicalNameHelper::isValid($name)); + } + + public function testUniqueAddsTheLowestFreeSuffix(): void { + $this->assertSame('sales', TechnicalNameHelper::unique('sales', [])); + $this->assertSame('sales_2', TechnicalNameHelper::unique('sales', ['sales' => true])); + $this->assertSame('sales_3', TechnicalNameHelper::unique('sales', ['sales' => true, 'sales_2' => true])); + } + + public function testUniqueStaysWithinTheMaximumLength(): void { + $base = str_repeat('a', 200); + + $name = TechnicalNameHelper::unique($base, [$base => true]); + + $this->assertSame(str_repeat('a', 198) . '_2', $name); + } + + public function testAssignKeepsFreeNamesAndMakesTheRestUnique(): void { + $names = TechnicalNameHelper::assign([ + 'a' => ['name' => null, 'fallback' => 'dashboard'], + 'b' => ['name' => 'dashboard', 'fallback' => 'overview'], + 'c' => ['name' => 'sales', 'fallback' => 'sales'], + 'd' => ['name' => 'sales', 'fallback' => 'sales'], + 'e' => ['name' => 'Not Valid', 'fallback' => 'x'], + 'f' => ['name' => '', 'fallback' => 'archive'], + ], ['archive' => true]); + + $this->assertSame([ + 'a' => 'dashboard_2', + 'b' => 'dashboard', + 'c' => 'sales', + 'd' => 'sales_2', + 'e' => 'not_valid', + 'f' => 'archive_2', + ], $names); + } + + public function testPageFallback(): void { + $this->assertSame('dashboard', TechnicalNameHelper::pageFallback(true, 'Anything')); + $this->assertSame('team_overview', TechnicalNameHelper::pageFallback(false, 'Team overview')); + $this->assertSame('page', TechnicalNameHelper::pageFallback(false, null)); + } +} diff --git a/tests/unit/Migration/Version2400Date20261015000002Test.php b/tests/unit/Migration/Version2400Date20261015000002Test.php new file mode 100644 index 0000000000..241e2a4aa8 --- /dev/null +++ b/tests/unit/Migration/Version2400Date20261015000002Test.php @@ -0,0 +1,61 @@ + 1, 'context_id' => 10, 'page_type' => 'startpage', 'title' => null, 'technical_name' => null], + ['id' => 2, 'context_id' => 10, 'page_type' => 'grid', 'title' => 'Team overview', 'technical_name' => null], + ['id' => 3, 'context_id' => 10, 'page_type' => 'grid', 'title' => 'Team: overview', 'technical_name' => null], + ['id' => 4, 'context_id' => 10, 'page_type' => 'grid', 'title' => null, 'technical_name' => null], + ['id' => 5, 'context_id' => 10, 'page_type' => 'grid', 'title' => '!!', 'technical_name' => ''], + ['id' => 6, 'context_id' => 11, 'page_type' => 'startpage', 'title' => null, 'technical_name' => null], + ['id' => 7, 'context_id' => 11, 'page_type' => 'grid', 'title' => 'Team overview', 'technical_name' => null], + ]); + + $this->assertSame([ + 1 => 'dashboard', + 2 => 'team_overview', + 3 => 'team_overview_2', + 4 => 'page_4', + 5 => 'page_5', + 6 => 'dashboard', + 7 => 'team_overview', + ], $names); + } + + public function testBackfillKeepsFreeNamesAndRenamesDuplicates(): void { + $names = Version2400Date20261015000002::backfillNames([ + ['id' => 1, 'context_id' => 10, 'page_type' => 'startpage', 'title' => null, 'technical_name' => null], + ['id' => 2, 'context_id' => 10, 'page_type' => 'grid', 'title' => 'Board', 'technical_name' => 'dashboard'], + ['id' => 3, 'context_id' => 10, 'page_type' => 'grid', 'title' => 'Sales', 'technical_name' => 'sales'], + ['id' => 4, 'context_id' => 10, 'page_type' => 'grid', 'title' => 'Sales', 'technical_name' => 'sales'], + ['id' => 5, 'context_id' => 10, 'page_type' => 'grid', 'title' => 'Other', 'technical_name' => 'Bad Name'], + ]); + + $this->assertSame([ + 1 => 'dashboard_2', + 4 => 'sales_2', + 5 => 'bad_name', + ], $names); + } + + public function testBackfillLeavesNamedPagesAlone(): void { + $this->assertSame([], Version2400Date20261015000002::backfillNames([ + ['id' => 1, 'context_id' => 10, 'page_type' => 'startpage', 'title' => null, 'technical_name' => 'dashboard'], + ['id' => 2, 'context_id' => 10, 'page_type' => 'grid', 'title' => 'Sales', 'technical_name' => 'sales'], + ])); + $this->assertSame([], Version2400Date20261015000002::backfillNames([])); + } +} diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 266827b7cd..e90eb2a8d5 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; @@ -156,6 +158,162 @@ public function testUpdateReordersStartPageContentsFromSubmittedNodes(): void { self::assertSame(10, $updatedContext->getPages()[301]['content'][502]['order']); } + public function testImportContextKeepsContentWithoutNodeAndOrdersByPosition(): void { + $table = new Table(); + $table->id = 40; + $table->setOwnership('user-1'); + + $this->contextMapper->expects($this->once())->method('insert') + ->willReturnCallback(static function (Context $context): Context { + $context->id = 9; + return $context; + }); + $this->contextNodeRelMapper->expects($this->once())->method('insert') + ->willReturnCallback(static function (ContextNodeRelation $relation): ContextNodeRelation { + $relation->id = 70; + return $relation; + }); + $insertedPages = []; + $this->pageMapper->expects($this->exactly(2))->method('insert') + ->willReturnCallback(static function (Page $page) use (&$insertedPages): Page { + $page->id = 80 + count($insertedPages); + $insertedPages[] = $page; + return $page; + }); + $inserted = []; + $this->pageContentMapper->expects($this->exactly(3))->method('insert') + ->willReturnCallback(static function (PageContent $content) use (&$inserted): PageContent { + $inserted[] = $content; + return $content; + }); + + $this->service->importContext($table, [ + 'name' => 'Imported', + 'nodes' => [['id' => 5, 'node_id' => 33, 'node_type' => 0, 'permissions' => 1]], + 'pages' => [ + ['id' => 1, 'page_type' => Page::TYPE_STARTPAGE, 'content' => [ + ['order' => 10, 'node_rel_id' => 5], + ]], + ['id' => 2, 'page_type' => Page::TYPE_GRID, 'title' => 'Overview', 'technical_name' => 'overview', 'content' => [ + ['order' => 10, 'node_rel_id' => 6, 'type' => 'data', 'configuration' => null, 'grid_x' => 0, 'grid_y' => 2, 'grid_width' => 12, 'grid_height' => 5], + ['order' => 20, 'node_rel_id' => null, 'type' => 'header', 'configuration' => ['title' => ''], 'content' => ['title' => 'Hi'], 'grid_x' => 0, 'grid_y' => 0, 'grid_width' => 12, 'grid_height' => 2], + ]], + ], + ], 33); + + self::assertSame('overview', $insertedPages[1]->getTechnicalName()); + self::assertSame('Overview', $insertedPages[1]->getTitle()); + self::assertSame('dashboard', $insertedPages[0]->getTechnicalName()); + + self::assertSame(70, $inserted[0]->getNodeRelId()); + self::assertSame(10, $inserted[0]->getOrder()); + self::assertNull($inserted[0]->getGridY()); + + // the node of the data widget is not part of the import + self::assertNull($inserted[1]->getNodeRelId()); + self::assertSame('data', $inserted[1]->getType()); + self::assertSame(20, $inserted[1]->getOrder()); + self::assertNull($inserted[2]->getNodeRelId()); + self::assertSame('header', $inserted[2]->getType()); + self::assertSame(10, $inserted[2]->getOrder()); + self::assertSame(['configuration' => ['title' => ''], 'content' => ['title' => 'Hi']], json_decode($inserted[2]->getConfiguration(), true)); + } + + public function testImportContextMakesInvalidOrDuplicateTechnicalNamesUnique(): void { + $table = new Table(); + $table->id = 40; + $table->setOwnership('user-1'); + $this->contextMapper->method('insert')->willReturnArgument(0); + $insertedPages = []; + $this->pageMapper->method('insert') + ->willReturnCallback(static function (Page $page) use (&$insertedPages): Page { + $insertedPages[] = $page; + return $page; + }); + + $this->service->importContext($table, [ + 'nodes' => [['id' => 5, 'node_id' => 33, 'node_type' => 0]], + 'pages' => [ + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'Not Valid'], + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'sales'], + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'sales'], + ], + ], 33); + + self::assertSame(['not_valid', 'sales', 'sales_2'], array_map(static fn (Page $page): ?string => $page->getTechnicalName(), $insertedPages)); + } + + public function testImportContextNamesPagesWithoutTechnicalName(): void { + $table = new Table(); + $table->id = 40; + $table->setOwnership('user-1'); + $this->contextMapper->method('insert')->willReturnArgument(0); + $insertedPages = []; + $this->pageMapper->method('insert') + ->willReturnCallback(static function (Page $page) use (&$insertedPages): Page { + $insertedPages[] = $page; + return $page; + }); + + $this->service->importContext($table, [ + 'nodes' => [['id' => 5, 'node_id' => 33, 'node_type' => 0]], + 'pages' => [ + ['page_type' => Page::TYPE_GRID, 'title' => 'Open cases'], + ['page_type' => Page::TYPE_STARTPAGE], + ['page_type' => Page::TYPE_GRID, 'title' => 'Open cases'], + ['page_type' => Page::TYPE_GRID, 'technical_name' => 'open_cases'], + ['page_type' => Page::TYPE_GRID], + ], + ], 33); + + self::assertSame( + ['open_cases_2', 'dashboard', 'open_cases_3', 'open_cases', 'page'], + array_map(static fn (Page $page): ?string => $page->getTechnicalName(), $insertedPages), + ); + } + + public function testCreateNamesTheStartPageDashboard(): void { + $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context { + $context->id = 9; + return $context; + }); + $insertedPages = []; + $this->pageMapper->expects($this->once())->method('insert') + ->willReturnCallback(static function (Page $page) use (&$insertedPages): Page { + $page->id = 21; + $insertedPages[] = $page; + return $page; + }); + + $context = $this->service->create('Sales', 'icon', '', [], 'user-1', 0); + + self::assertSame(Page::TYPE_STARTPAGE, $insertedPages[0]->getPageType()); + self::assertSame('dashboard', $insertedPages[0]->getTechnicalName()); + self::assertSame('dashboard', $context->getPages()[21]['technicalName']); + } + + public function testAddNodeRelToPageAddsToTheStartPageWithoutPosition(): void { + $context = new Context(); + $context->setPages([ + 3 => ['id' => 3, 'page_type' => Page::TYPE_GRID, 'content' => []], + 4 => ['id' => 4, 'page_type' => Page::TYPE_STARTPAGE, 'content' => []], + ]); + $relation = new ContextNodeRelation(); + $relation->id = 12; + $relation->setContextId(7); + $this->contextMapper->method('findById')->with(7)->willReturn($context); + $this->pageContentMapper->method('findByPageAndNodeRelation')->with(4, 12)->willReturn(null); + $this->pageContentMapper->expects($this->once())->method('insert')->willReturnArgument(0); + + $content = $this->service->addNodeRelToPage($relation, 30); + + self::assertSame(4, $content->getPageId()); + self::assertSame(12, $content->getNodeRelId()); + self::assertSame(30, $content->getOrder()); + self::assertNull($content->getGridX()); + self::assertNull($content->getGridY()); + } + private function buildPageContent(int $id, int $pageId, int $nodeRelId, int $order): PageContent { $pageContent = new PageContent(); $pageContent->id = $id;