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;