diff --git a/appinfo/info.xml b/appinfo/info.xml
index dd34d3a45f..4edcef88db 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
+ 2.5.0-dev.0
AGPL-3.0-or-later
Nextcloud GmbH and Nextcloud contributors
Tables
diff --git a/lib/Controller/ContextController.php b/lib/Controller/ContextController.php
index e87f8ca440..434e2137bb 100644
--- a/lib/Controller/ContextController.php
+++ b/lib/Controller/ContextController.php
@@ -15,6 +15,7 @@
use OCA\Tables\Errors\InternalError;
use OCA\Tables\Errors\NotFoundError;
use OCA\Tables\Errors\PermissionError;
+use OCA\Tables\Helper\IconHelper;
use OCA\Tables\Middleware\Attribute\RequirePermission;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\ColumnService;
@@ -386,11 +387,7 @@ public function importScheme(int $contextId, string $name, string $iconName, str
}
protected function isValidIcon(string $iconName): bool {
- if ($iconName === '' || !preg_match('/^[a-zA-Z0-9-]+$/', $iconName)) {
- return false;
- }
- $iconPath = dirname(__DIR__, 2) . '/img/material/' . $iconName . '.svg';
- return file_exists($iconPath);
+ return IconHelper::isValid($iconName);
}
/**
diff --git a/lib/Db/Page.php b/lib/Db/Page.php
index 5ceeb1bd00..db625c051d 100644
--- a/lib/Db/Page.php
+++ b/lib/Db/Page.php
@@ -15,22 +15,49 @@
* @method setContextId(int $value): void
* @method getPageType(): string
* @method setPageType(string $value): void
+ * @method getTechnicalName(): ?string
+ * @method setTechnicalName(?string $value): void
+ * @method getTitle(): ?string
+ * @method setTitle(?string $value): void
+ * @method getIcon(): ?string
+ * @method setIcon(?string $value): void
+ * @method getDescription(): ?string
+ * @method setDescription(?string $value): void
*/
class Page extends Entity implements \JsonSerializable {
public const TYPE_STARTPAGE = 'startpage';
+ public const TYPE_GRID = 'grid';
+ public const START_PAGE_NAME = 'dashboard';
+ public const DEFAULT_NAME_PREFIX = 'page_';
protected ?int $contextId = null;
protected ?string $pageType = null;
+ protected ?string $technicalName = null;
+ protected ?string $title = null;
+ protected ?string $icon = null;
+ protected ?string $description = null;
public function __construct() {
$this->addType('id', 'integer');
}
+ /**
+ * Whether a technical name has the form of the automatic `page_`. Such names are
+ * reserved, so a given name never collides with the name a later page gets.
+ */
+ public static function isDefaultName(string $technicalName): bool {
+ return preg_match('/^' . self::DEFAULT_NAME_PREFIX . '[0-9]+$/', $technicalName) === 1;
+ }
+
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
'contextId' => $this->getContextId(),
'pageType' => $this->getPageType(),
+ 'technicalName' => $this->getTechnicalName(),
+ 'title' => $this->getTitle(),
+ 'icon' => $this->getIcon(),
+ 'description' => $this->getDescription(),
];
}
}
diff --git a/lib/Db/PageMapper.php b/lib/Db/PageMapper.php
index b7a1ce428a..cd85c17f94 100644
--- a/lib/Db/PageMapper.php
+++ b/lib/Db/PageMapper.php
@@ -8,17 +8,34 @@
namespace OCA\Tables\Db;
use OCP\AppFramework\Db\QBMapper;
+use OCP\AppFramework\Db\TTransactional;
+use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/** @template-extends QBMapper */
class PageMapper extends QBMapper {
+ use TTransactional;
+
protected string $table = 'tables_contexts_page';
public function __construct(IDBConnection $db) {
parent::__construct($db, $this->table, Page::class);
}
+ /**
+ * Inserts a page under the default technical name `page_`.
+ *
+ * @throws Exception
+ */
+ public function insertWithDefaultName(Page $page): Page {
+ return $this->atomic(function () use ($page): Page {
+ $page = $this->insert($page);
+ $page->setTechnicalName(Page::DEFAULT_NAME_PREFIX . $page->getId());
+ return $this->update($page);
+ }, $this->db);
+ }
+
/**
* @return int[]
*/
diff --git a/lib/Db/Widget.php b/lib/Db/Widget.php
new file mode 100644
index 0000000000..8a0caa3fbd
--- /dev/null
+++ b/lib/Db/Widget.php
@@ -0,0 +1,110 @@
+addType('id', 'integer');
+ $this->addType('pageId', 'integer');
+ $this->addType('gridX', 'integer');
+ $this->addType('gridY', 'integer');
+ $this->addType('gridWidth', 'integer');
+ $this->addType('gridHeight', 'integer');
+ }
+
+ /**
+ * @return array
+ */
+ public function getSettingsArray(): array {
+ $settings = $this->getSettings();
+ if ($settings === null || $settings === '') {
+ return [];
+ }
+ $decoded = json_decode($settings, true);
+ /** @var array */
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ /**
+ * Stores the settings as one JSON object, `{}` when there are none.
+ *
+ * @param array $settings
+ * @throws \JsonException
+ */
+ public function setSettingsArray(array $settings): void {
+ $this->setSettings(json_encode((object)$settings, JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * The settings as an object, so they serialise as a JSON object also when empty,
+ * ordered by name: a JSON column does not keep the stored order on every database.
+ */
+ public function getSettingsObject(): \stdClass {
+ $settings = $this->getSettingsArray();
+ ksort($settings);
+ return (object)$settings;
+ }
+
+ public function jsonSerialize(): array {
+ return [
+ 'id' => $this->getId(),
+ 'pageId' => $this->getPageId(),
+ 'type' => $this->getType(),
+ 'title' => $this->getTitle(),
+ 'icon' => $this->getIcon(),
+ 'description' => $this->getDescription(),
+ 'gridX' => $this->getGridX(),
+ 'gridY' => $this->getGridY(),
+ 'gridWidth' => $this->getGridWidth(),
+ 'gridHeight' => $this->getGridHeight(),
+ 'settings' => $this->getSettingsObject(),
+ ];
+ }
+}
diff --git a/lib/Db/WidgetMapper.php b/lib/Db/WidgetMapper.php
new file mode 100644
index 0000000000..9843b30c77
--- /dev/null
+++ b/lib/Db/WidgetMapper.php
@@ -0,0 +1,78 @@
+ */
+class WidgetMapper extends QBMapper {
+ protected string $table = 'tables_contexts_widget';
+
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, $this->table, Widget::class);
+ }
+
+ /**
+ * @param int[] $pageIds
+ * @return Widget[] ordered by page and grid position
+ * @throws Exception
+ */
+ public function findByPageIds(array $pageIds): array {
+ if ($pageIds === []) {
+ return [];
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')
+ ->from($this->table)
+ ->where($qb->expr()->in('page_id', $qb->createNamedParameter($pageIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->orderBy('page_id', 'ASC')
+ ->addOrderBy('grid_y', 'ASC')
+ ->addOrderBy('grid_x', 'ASC')
+ ->addOrderBy('id', 'ASC');
+
+ return $this->findEntities($qb);
+ }
+
+ /**
+ * @return int[]
+ * @throws Exception
+ */
+ public function findIdsByPageId(int $pageId): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('id')
+ ->from($this->table)
+ ->where($qb->expr()->eq('page_id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT)));
+
+ $result = $qb->executeQuery();
+ $ids = [];
+ while ($row = $result->fetchAssociative()) {
+ $ids[] = (int)$row['id'];
+ }
+ $result->closeCursor();
+ return $ids;
+ }
+
+ /**
+ * @param int[] $widgetIds
+ * @throws Exception
+ */
+ public function deleteByIds(array $widgetIds): void {
+ if ($widgetIds === []) {
+ return;
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->table)
+ ->where($qb->expr()->in('id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->executeStatement();
+ }
+}
diff --git a/lib/Db/WidgetSource.php b/lib/Db/WidgetSource.php
new file mode 100644
index 0000000000..30091a6d2f
--- /dev/null
+++ b/lib/Db/WidgetSource.php
@@ -0,0 +1,31 @@
+addType('widgetId', 'integer');
+ $this->addType('nodeRelId', 'integer');
+ }
+}
diff --git a/lib/Db/WidgetSourceMapper.php b/lib/Db/WidgetSourceMapper.php
new file mode 100644
index 0000000000..9041e7d849
--- /dev/null
+++ b/lib/Db/WidgetSourceMapper.php
@@ -0,0 +1,99 @@
+
+ */
+class WidgetSourceMapper extends QBMapper {
+ protected string $table = 'tables_contexts_widget_source';
+
+ public function __construct(IDBConnection $db) {
+ parent::__construct($db, $this->table, WidgetSource::class);
+ }
+
+ /**
+ * @param int[] $widgetIds
+ * @return array node relation ids by widget id
+ * @throws Exception
+ */
+ public function findNodeRelIdsByWidgetIds(array $widgetIds): array {
+ if ($widgetIds === []) {
+ return [];
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('widget_id', 'node_rel_id')
+ ->from($this->table)
+ ->where($qb->expr()->in('widget_id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ $result = $qb->executeQuery();
+ $sources = [];
+ while ($row = $result->fetchAssociative()) {
+ $sources[(int)$row['widget_id']] = (int)$row['node_rel_id'];
+ }
+ $result->closeCursor();
+ return $sources;
+ }
+
+ /**
+ * Points a widget at a node relation, or removes its source when null.
+ *
+ * @throws Exception
+ */
+ public function setSource(int $widgetId, ?int $nodeRelId): void {
+ $this->deleteByWidgetIds([$widgetId]);
+ if ($nodeRelId === null) {
+ return;
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->insert($this->table)
+ ->values([
+ 'widget_id' => $qb->createNamedParameter($widgetId, IQueryBuilder::PARAM_INT),
+ 'node_rel_id' => $qb->createNamedParameter($nodeRelId, IQueryBuilder::PARAM_INT),
+ ])
+ ->executeStatement();
+ }
+
+ /**
+ * @param int[] $widgetIds
+ * @throws Exception
+ */
+ public function deleteByWidgetIds(array $widgetIds): void {
+ if ($widgetIds === []) {
+ return;
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->table)
+ ->where($qb->expr()->in('widget_id', $qb->createNamedParameter($widgetIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->executeStatement();
+ }
+
+ /**
+ * @param int[] $nodeRelIds
+ * @throws Exception
+ */
+ public function deleteByNodeRelIds(array $nodeRelIds): void {
+ if ($nodeRelIds === []) {
+ return;
+ }
+ $qb = $this->db->getQueryBuilder();
+ $qb->delete($this->table)
+ ->where($qb->expr()->in('node_rel_id', $qb->createNamedParameter($nodeRelIds, IQueryBuilder::PARAM_INT_ARRAY)))
+ ->executeStatement();
+ }
+}
diff --git a/lib/Helper/IconHelper.php b/lib/Helper/IconHelper.php
new file mode 100644
index 0000000000..a007b06946
--- /dev/null
+++ b/lib/Helper/IconHelper.php
@@ -0,0 +1,22 @@
+`.
+ */
+class Version2500Date20261022000000 extends SimpleMigrationStep {
+ private const PREFIX = 'tables_contexts_';
+ private const PAGE_TABLE = self::PREFIX . 'page';
+ private const PAGE_NAME_INDEX = 'tables_ctx_page_name_uq';
+ private const START_PAGE_NAME = 'dashboard';
+
+ public function __construct(
+ private readonly IDBConnection $db,
+ ) {
+ }
+
+ #[Override]
+ public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+ /** @var ISchemaWrapper $schema */
+ $schema = $schemaClosure();
+
+ $this->extendPageTable($schema);
+ $this->haveWidgetTable($schema);
+ $this->haveWidgetSourceTable($schema);
+
+ return $schema;
+ }
+
+ private function extendPageTable(ISchemaWrapper $schema): void {
+ if (!$schema->hasTable(self::PAGE_TABLE)) {
+ return;
+ }
+ $table = $schema->getTable(self::PAGE_TABLE);
+ if (!$table->hasColumn('title')) {
+ $table->addColumn('title', Types::STRING, ['notnull' => false, 'length' => 200]);
+ }
+ if (!$table->hasColumn('icon')) {
+ $table->addColumn('icon', Types::STRING, ['notnull' => false, 'length' => 64]);
+ }
+ if (!$table->hasColumn('description')) {
+ $table->addColumn('description', Types::TEXT, ['notnull' => false]);
+ }
+ if (!$table->hasColumn('technical_name')) {
+ $table->addColumn('technical_name', Types::STRING, ['notnull' => false, 'length' => TechnicalNameValidator::MAX_LENGTH]);
+ }
+ // Also serves lookups by context_id. Safe while existing pages have no name yet:
+ // pgsql, mysql and sqlite, the databases Tables supports, treat NULLs in a unique
+ // index as distinct.
+ if (!$table->hasIndex(self::PAGE_NAME_INDEX)) {
+ $table->addUniqueIndex(['context_id', 'technical_name'], self::PAGE_NAME_INDEX);
+ }
+ }
+
+ #[Override]
+ public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
+ $select = $this->db->getQueryBuilder();
+ $select->select('id', 'context_id', 'page_type', 'technical_name')
+ ->from(self::PAGE_TABLE)
+ ->orderBy('context_id')
+ ->addOrderBy('id');
+ $result = $select->executeQuery();
+ $rows = $result->fetchAllAssociative();
+ $result->closeCursor();
+
+ $names = self::backfillNames($rows);
+ if ($names === []) {
+ return;
+ }
+
+ $update = $this->db->getQueryBuilder();
+ $update->update(self::PAGE_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 (\Throwable $e) {
+ $this->db->rollBack();
+ throw $e;
+ }
+ }
+
+ /**
+ * The technical name of every page row that has none, by page id. The start page of
+ * an application gets `dashboard`, unless an earlier page holds it; every other page
+ * gets `page_`.
+ *
+ * @param list> $rows page rows
+ * @return array
+ */
+ public static function backfillNames(array $rows): array {
+ usort($rows, static fn (array $a, array $b): int => [(int)$a['context_id'], (int)$a['id']] <=> [(int)$b['context_id'], (int)$b['id']]);
+
+ $taken = [];
+ foreach ($rows as $row) {
+ if (is_string($row['technical_name'] ?? null) && $row['technical_name'] !== '') {
+ $taken[(int)$row['context_id']][$row['technical_name']] = true;
+ }
+ }
+
+ $names = [];
+ foreach ($rows as $row) {
+ if (is_string($row['technical_name'] ?? null) && $row['technical_name'] !== '') {
+ continue;
+ }
+ $contextId = (int)$row['context_id'];
+ $technicalName = $row['page_type'] === Page::TYPE_STARTPAGE && !isset($taken[$contextId][self::START_PAGE_NAME])
+ ? self::START_PAGE_NAME
+ : 'page_' . (int)$row['id'];
+ $taken[$contextId][$technicalName] = true;
+ $names[(int)$row['id']] = $technicalName;
+ }
+ return $names;
+ }
+
+ private function haveWidgetTable(ISchemaWrapper $schema): void {
+ if ($schema->hasTable(self::PREFIX . 'widget')) {
+ return;
+ }
+ $table = $schema->createTable(self::PREFIX . 'widget');
+ $table->addColumn('id', Types::INTEGER, ['autoincrement' => true, 'notnull' => true]);
+ $table->addColumn('page_id', Types::INTEGER, ['notnull' => true]);
+ $table->addColumn('type', Types::STRING, ['notnull' => true, 'length' => 64]);
+ $table->addColumn('title', Types::STRING, ['notnull' => false, 'length' => 200]);
+ $table->addColumn('icon', Types::STRING, ['notnull' => false, 'length' => 64]);
+ $table->addColumn('description', Types::TEXT, ['notnull' => false]);
+ $table->addColumn('grid_x', Types::SMALLINT, ['notnull' => true, 'default' => 0]);
+ $table->addColumn('grid_y', Types::INTEGER, ['notnull' => true, 'default' => 0]);
+ $table->addColumn('grid_width', Types::SMALLINT, ['notnull' => true, 'default' => 1]);
+ $table->addColumn('grid_height', Types::SMALLINT, ['notnull' => true, 'default' => 1]);
+ $table->addColumn('settings', Types::JSON, ['notnull' => false]);
+ $table->setPrimaryKey(['id']);
+ $table->addIndex(['page_id'], 'tables_ctx_widget_page_idx');
+ }
+
+ private function haveWidgetSourceTable(ISchemaWrapper $schema): void {
+ if ($schema->hasTable(self::PREFIX . 'widget_source')) {
+ return;
+ }
+ $table = $schema->createTable(self::PREFIX . 'widget_source');
+ $table->addColumn('widget_id', Types::INTEGER, ['notnull' => true]);
+ $table->addColumn('node_rel_id', Types::INTEGER, ['notnull' => true]);
+ $table->setPrimaryKey(['widget_id']);
+ $table->addIndex(['node_rel_id'], 'tables_ctx_wsource_node_idx');
+ }
+}
diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php
index 828897ad6f..1294fda7db 100644
--- a/lib/Service/ContextService.php
+++ b/lib/Service/ContextService.php
@@ -32,6 +32,7 @@
use OCA\Tables\Model\SortRuleSet;
use OCA\Tables\Model\ViewSettings;
use OCA\Tables\Service\Support\AuditLogServiceInterface;
+use OCA\Tables\Validation\TechnicalNameValidator;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\TTransactional;
@@ -547,6 +548,7 @@ protected function insertPage(Context $context): void {
$page = new Page();
$page->setContextId($context->getId());
$page->setPageType(Page::TYPE_STARTPAGE);
+ $page->setTechnicalName(Page::START_PAGE_NAME);
$this->pageMapper->insert($page);
$addedPage = $page->jsonSerialize();
@@ -603,6 +605,32 @@ protected function insertNodesFromArray(Context $context, array $nodes): void {
$context->setNodes($addedNodes);
}
+ /**
+ * The exported pages that can be imported: a start page or a grid page, and at most
+ * one start page. Others are skipped with a warning.
+ *
+ * @param array $pages exported pages
+ * @return array>
+ */
+ private function importablePages(array $pages): array {
+ $importable = [];
+ $hasStartPage = false;
+ foreach ($pages as $key => $pageArr) {
+ $type = is_array($pageArr) ? ($pageArr['page_type'] ?? $pageArr['pageType'] ?? null) : null;
+ if ($type !== Page::TYPE_STARTPAGE && $type !== Page::TYPE_GRID) {
+ $this->logger->warning('Skipping an imported page: its type is not ' . Page::TYPE_STARTPAGE . ' or ' . Page::TYPE_GRID . '.');
+ continue;
+ }
+ if ($type === Page::TYPE_STARTPAGE && $hasStartPage) {
+ $this->logger->warning('Skipping an imported page: an application has one start page.');
+ continue;
+ }
+ $hasStartPage = $hasStartPage || $type === Page::TYPE_STARTPAGE;
+ $importable[$key] = $pageArr;
+ }
+ return $importable;
+ }
+
/**
* @param Table $table
* @param array $context
@@ -653,11 +681,18 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C
// insert pages
$pageMap = [];
if (!empty($context['pages'])) {
- foreach ($context['pages'] as $pageArr) {
+ $pages = $this->importablePages($context['pages']);
+ $technicalNames = $this->importedPageNames($pages);
+ foreach ($pages as $pageKey => $pageArr) {
$page = new Page();
$page->setContextId($newContext->getId());
$page->setPageType($pageArr['page_type'] ?? $pageArr['pageType'] ?? '');
- $page = $this->pageMapper->insert($page);
+ if ($technicalNames[$pageKey] === null) {
+ $page = $this->pageMapper->insertWithDefaultName($page);
+ } else {
+ $page->setTechnicalName($technicalNames[$pageKey]);
+ $page = $this->pageMapper->insert($page);
+ }
$pageMap[$pageArr['id'] ?? null] = $page;
// insert page conten
@@ -678,6 +713,36 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C
return $newContext;
}
+ /**
+ * The technical names of the pages of an imported application, by page key. A valid
+ * exported name is kept unless an earlier page of the import uses it or it has the
+ * reserved form `page_`, a start page without one gets `dashboard` while it is
+ * free, and null leaves the default name.
+ *
+ * @param array $pages exported pages
+ * @return array
+ */
+ private function importedPageNames(array $pages): array {
+ $names = [];
+ $taken = [];
+ foreach ($pages as $key => $pageArr) {
+ $name = is_array($pageArr) ? ($pageArr['technical_name'] ?? $pageArr['technicalName'] ?? null) : null;
+ $names[$key] = null;
+ if (is_string($name) && TechnicalNameValidator::isValid($name) && !Page::isDefaultName($name) && !isset($taken[$name])) {
+ $names[$key] = $name;
+ $taken[$name] = true;
+ }
+ }
+ foreach ($pages as $key => $pageArr) {
+ $isStartPage = is_array($pageArr) && ($pageArr['page_type'] ?? $pageArr['pageType'] ?? '') === Page::TYPE_STARTPAGE;
+ if ($names[$key] === null && $isStartPage && !isset($taken[Page::START_PAGE_NAME])) {
+ $names[$key] = Page::START_PAGE_NAME;
+ $taken[Page::START_PAGE_NAME] = true;
+ }
+ }
+ return $names;
+ }
+
/**
* @param int $contextId
*
diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php
index 7ee532d32f..d2b18037a2 100644
--- a/lib/Service/ViewService.php
+++ b/lib/Service/ViewService.php
@@ -38,6 +38,7 @@
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\Support\AuditLogServiceInterface;
use OCA\Tables\Service\ValueObject\ViewColumnInformation;
+use OCA\Tables\Validation\TechnicalNameValidator;
use OCA\Tables\Vendor\Symfony\Component\Uid\Uuid;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
@@ -809,10 +810,10 @@ private function buildDefaultTechnicalName(int $viewId): string {
* @throws BadRequestError
*/
private function assertTechnicalNameValid(string $technicalName): void {
- if (strlen($technicalName) > 200) {
- throw new BadRequestError('Technical name must not exceed 200 characters.');
+ if (TechnicalNameValidator::isTooLong($technicalName)) {
+ throw new BadRequestError('Technical name must not exceed ' . TechnicalNameValidator::MAX_LENGTH . ' characters.');
}
- if (!preg_match('/^[a-z][a-z0-9_]*$/', $technicalName)) {
+ if (!TechnicalNameValidator::hasValidCharacters($technicalName)) {
throw new BadRequestError('Technical name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.');
}
}
diff --git a/lib/Validation/ColumnDtoValidator.php b/lib/Validation/ColumnDtoValidator.php
index b2e0c1da49..058763c665 100644
--- a/lib/Validation/ColumnDtoValidator.php
+++ b/lib/Validation/ColumnDtoValidator.php
@@ -12,8 +12,6 @@
use OCA\Tables\Service\ValueObject\Title;
class ColumnDtoValidator {
- private const TECHNICAL_NAME_MAX_LENGTH = 200;
-
/**
* reserved keys to avoid confusion with meta fields and common row keys.
*/
@@ -69,15 +67,15 @@ private function validateTechnicalName(ColumnDto $columnDto): void {
throw new BadRequestError('Technical name must not be empty.');
}
- if (strlen($technicalName) > self::TECHNICAL_NAME_MAX_LENGTH) {
- throw new BadRequestError('Technical name must be maximum ' . self::TECHNICAL_NAME_MAX_LENGTH . ' characters long.');
+ if (TechnicalNameValidator::isTooLong($technicalName)) {
+ throw new BadRequestError('Technical name must be maximum ' . TechnicalNameValidator::MAX_LENGTH . ' characters long.');
}
if (in_array($technicalName, self::RESERVED_TECHNICAL_NAMES, true)) {
throw new BadRequestError('Technical name is reserved.');
}
- if (preg_match('/^[a-z][a-z0-9_]*$/', $technicalName) !== 1) {
+ if (!TechnicalNameValidator::hasValidCharacters($technicalName)) {
throw new BadRequestError('Technical name must start with a letter and contain only lowercase letters, digits, and underscores.');
}
}
diff --git a/lib/Validation/TechnicalNameValidator.php b/lib/Validation/TechnicalNameValidator.php
new file mode 100644
index 0000000000..f93af6856b
--- /dev/null
+++ b/lib/Validation/TechnicalNameValidator.php
@@ -0,0 +1,30 @@
+ self::MAX_LENGTH;
+ }
+
+ public static function hasValidCharacters(string $technicalName): bool {
+ return preg_match(self::PATTERN, $technicalName) === 1;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index a142c3ca0f..3a5f9d5939 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "tables",
- "version": "2.4.0",
+ "version": "2.5.0-dev.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tables",
- "version": "2.4.0",
+ "version": "2.5.0-dev.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@material-symbols/svg-400": "^0.48.1",
diff --git a/package.json b/package.json
index 858a25a311..d3d93cceba 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "tables",
"description": "Manage data within tables.",
- "version": "2.4.0",
+ "version": "2.5.0-dev.0",
"author": "Nextcloud GmbH and Nextcloud contributors",
"bugs": {
"url": "https://github.com/nextcloud/tables/issues"
diff --git a/tests/unit/Database/DbErrorStub.php b/tests/unit/Database/DbErrorStub.php
new file mode 100644
index 0000000000..17ac605167
--- /dev/null
+++ b/tests/unit/Database/DbErrorStub.php
@@ -0,0 +1,31 @@
+reason;
+ }
+}
diff --git a/tests/unit/Db/PageTest.php b/tests/unit/Db/PageTest.php
new file mode 100644
index 0000000000..6f81f8d89a
--- /dev/null
+++ b/tests/unit/Db/PageTest.php
@@ -0,0 +1,111 @@
+mapper = new PageMapper($this->connectionAdapter);
+ }
+
+ protected function tearDown(): void {
+ foreach ([self::CONTEXT, self::OTHER_CONTEXT] as $contextId) {
+ foreach ($this->mapper->getPageIdsForContext($contextId) as $pageId) {
+ $this->mapper->deleteByPageId($pageId);
+ }
+ }
+ parent::tearDown();
+ }
+
+ public function testAGridPageKeepsItsTitleIconAndDescription(): void {
+ $page = new Page();
+ $page->setContextId(self::CONTEXT);
+ $page->setPageType(Page::TYPE_GRID);
+ $page->setTechnicalName('overview');
+ $page->setTitle('Overview');
+ $page->setIcon('star');
+ $page->setDescription('Open tasks by team');
+ $page = $this->mapper->insert($page);
+
+ $this->assertSame([
+ 'id' => $page->getId(),
+ 'contextId' => self::CONTEXT,
+ 'pageType' => 'grid',
+ 'technicalName' => 'overview',
+ 'title' => 'Overview',
+ 'icon' => 'star',
+ 'description' => 'Open tasks by team',
+ ], $this->load($page->getId())->jsonSerialize());
+ }
+
+ public function testAPageWithoutAGivenNameIsNamedByItsId(): void {
+ $page = new Page();
+ $page->setContextId(self::CONTEXT);
+ $page->setPageType(Page::TYPE_GRID);
+ $page->setTitle('Overview');
+ $page = $this->mapper->insertWithDefaultName($page);
+
+ $this->assertSame('page_' . $page->getId(), $page->getTechnicalName());
+ $this->assertSame('page_' . $page->getId(), $this->load($page->getId())->getTechnicalName());
+ }
+
+ public function testOnlyTheAutomaticFormIsADefaultName(): void {
+ foreach (['page_7' => true, 'page_123456' => true, 'page' => false, 'page_' => false, 'page_7a' => false, 'my_page_7' => false, 'dashboard' => false] as $name => $isDefault) {
+ $this->assertSame($isDefault, Page::isDefaultName($name), $name);
+ }
+ }
+
+ public function testATechnicalNameIsUniqueWithinAnApplication(): void {
+ $this->insertNamed(self::CONTEXT, 'sales');
+ $this->insertNamed(self::OTHER_CONTEXT, 'sales');
+
+ try {
+ $this->insertNamed(self::CONTEXT, 'sales');
+ $this->fail('The unique index accepted a second page named sales');
+ } catch (Exception $e) {
+ $this->assertSame(Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION, $e->getReason());
+ }
+ $this->assertCount(1, $this->mapper->getPageIdsForContext(self::CONTEXT));
+ }
+
+ private function insertNamed(int $contextId, string $technicalName): Page {
+ $page = new Page();
+ $page->setContextId($contextId);
+ $page->setPageType(Page::TYPE_GRID);
+ $page->setTechnicalName($technicalName);
+ return $this->mapper->insert($page);
+ }
+
+ private function load(int $pageId): Page {
+ $qb = $this->connectionAdapter->getQueryBuilder();
+ $qb->select('*')
+ ->from('tables_contexts_page')
+ ->where($qb->expr()->eq('id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT)));
+ $result = $qb->executeQuery();
+ $row = $result->fetchAssociative();
+ $result->closeCursor();
+ $this->assertIsArray($row);
+ return Page::fromRow($row);
+ }
+}
diff --git a/tests/unit/Db/WidgetMapperTest.php b/tests/unit/Db/WidgetMapperTest.php
new file mode 100644
index 0000000000..75e0219e17
--- /dev/null
+++ b/tests/unit/Db/WidgetMapperTest.php
@@ -0,0 +1,123 @@
+mapper = new WidgetMapper($this->connectionAdapter);
+ }
+
+ protected function tearDown(): void {
+ $this->mapper->deleteByIds(array_merge(
+ $this->mapper->findIdsByPageId(self::PAGE),
+ $this->mapper->findIdsByPageId(self::OTHER_PAGE),
+ ));
+ parent::tearDown();
+ }
+
+ public function testAWidgetIsStoredWithAllItsFields(): void {
+ $widget = $this->widget(self::PAGE, 'header', 'Welcome', 2, 3, 8, 4, ['heading' => 'Projects', 'showTitle' => false]);
+ $widget->setIcon('star');
+ $widget->setDescription('What the team works on');
+ $inserted = $this->mapper->insert($widget);
+
+ $found = $this->mapper->findByPageIds([self::PAGE]);
+
+ $this->assertCount(1, $found);
+ $serialized = $found[0]->jsonSerialize();
+ $serialized['settings'] = json_encode($serialized['settings']);
+ $this->assertSame([
+ 'id' => $inserted->getId(),
+ 'pageId' => self::PAGE,
+ 'type' => 'header',
+ 'title' => 'Welcome',
+ 'icon' => 'star',
+ 'description' => 'What the team works on',
+ 'gridX' => 2,
+ 'gridY' => 3,
+ 'gridWidth' => 8,
+ 'gridHeight' => 4,
+ 'settings' => '{"heading":"Projects","showTitle":false}',
+ ], $serialized);
+ }
+
+ public function testWidgetsAreFoundByPageInGridOrder(): void {
+ $bottom = $this->insert(self::PAGE, 'data', null, 0, 5);
+ $topRight = $this->insert(self::PAGE, 'text', null, 6, 0);
+ $topLeft = $this->insert(self::PAGE, 'text', null, 0, 0);
+ $other = $this->insert(self::OTHER_PAGE, 'text', null, 0, 0);
+
+ $ids = static fn (array $widgets): array => array_map(static fn (Widget $widget): int => $widget->getId(), $widgets);
+
+ $this->assertSame([$topLeft->getId(), $topRight->getId(), $bottom->getId()], $ids($this->mapper->findByPageIds([self::PAGE])));
+ $this->assertSame(
+ [$topLeft->getId(), $topRight->getId(), $bottom->getId(), $other->getId()],
+ $ids($this->mapper->findByPageIds([self::OTHER_PAGE, self::PAGE])),
+ );
+ $this->assertSame([], $this->mapper->findByPageIds([]));
+ }
+
+ public function testIdsOfAPageAreListed(): void {
+ $first = $this->insert(self::PAGE, 'text', null, 0, 0);
+ $second = $this->insert(self::PAGE, 'text', null, 0, 1);
+ $this->insert(self::OTHER_PAGE, 'text', null, 0, 0);
+
+ $ids = $this->mapper->findIdsByPageId(self::PAGE);
+ sort($ids);
+
+ $this->assertSame([$first->getId(), $second->getId()], $ids);
+ }
+
+ public function testOnlyTheGivenWidgetsAreDeleted(): void {
+ $deleted = $this->insert(self::PAGE, 'text', null, 0, 0);
+ $kept = $this->insert(self::PAGE, 'text', null, 0, 1);
+
+ $this->mapper->deleteByIds([$deleted->getId()]);
+ $this->mapper->deleteByIds([]);
+
+ $this->assertSame([$kept->getId()], $this->mapper->findIdsByPageId(self::PAGE));
+ }
+
+ /**
+ * @param array $settings
+ */
+ private function insert(int $pageId, string $type, ?string $title, int $x, int $y, int $width = 6, int $height = 2, array $settings = []): Widget {
+ return $this->mapper->insert($this->widget($pageId, $type, $title, $x, $y, $width, $height, $settings));
+ }
+
+ /**
+ * @param array $settings
+ */
+ private function widget(int $pageId, string $type, ?string $title, int $x, int $y, int $width = 6, int $height = 2, array $settings = []): Widget {
+ $widget = new Widget();
+ $widget->setPageId($pageId);
+ $widget->setType($type);
+ $widget->setTitle($title);
+ $widget->setGridX($x);
+ $widget->setGridY($y);
+ $widget->setGridWidth($width);
+ $widget->setGridHeight($height);
+ $widget->setSettingsArray($settings);
+ return $widget;
+ }
+}
diff --git a/tests/unit/Db/WidgetSourceMapperTest.php b/tests/unit/Db/WidgetSourceMapperTest.php
new file mode 100644
index 0000000000..9b5161f968
--- /dev/null
+++ b/tests/unit/Db/WidgetSourceMapperTest.php
@@ -0,0 +1,67 @@
+mapper = new WidgetSourceMapper($this->connectionAdapter);
+ }
+
+ protected function tearDown(): void {
+ $this->mapper->deleteByWidgetIds(self::WIDGETS);
+ parent::tearDown();
+ }
+
+ public function testSettingASourceReplacesTheOldOneAndNullRemovesIt(): void {
+ [$first, $second, $third] = self::WIDGETS;
+ $this->mapper->setSource($first, 21);
+ $this->mapper->setSource($second, 22);
+
+ $this->mapper->setSource($first, 23);
+ $this->mapper->setSource($second, null);
+
+ $this->assertSame([$first => 23], $this->mapper->findNodeRelIdsByWidgetIds(self::WIDGETS));
+ $this->assertSame([], $this->mapper->findNodeRelIdsByWidgetIds([$third]));
+ $this->assertSame([], $this->mapper->findNodeRelIdsByWidgetIds([]));
+ }
+
+ public function testDeletingByWidgetKeepsOtherWidgetsSources(): void {
+ [$first, $second] = self::WIDGETS;
+ $this->mapper->setSource($first, 31);
+ $this->mapper->setSource($second, 32);
+
+ $this->mapper->deleteByWidgetIds([$first]);
+
+ $this->assertSame([$second => 32], $this->mapper->findNodeRelIdsByWidgetIds(self::WIDGETS));
+ }
+
+ public function testDeletingByNodeRelationRemovesEverySourceShowingIt(): void {
+ [$first, $second, $third] = self::WIDGETS;
+ $this->mapper->setSource($first, 41);
+ $this->mapper->setSource($second, 41);
+ $this->mapper->setSource($third, 42);
+
+ $this->mapper->deleteByNodeRelIds([41]);
+ $this->mapper->deleteByNodeRelIds([]);
+
+ $this->assertSame([$third => 42], $this->mapper->findNodeRelIdsByWidgetIds(self::WIDGETS));
+ }
+}
diff --git a/tests/unit/Db/WidgetTest.php b/tests/unit/Db/WidgetTest.php
new file mode 100644
index 0000000000..449702439c
--- /dev/null
+++ b/tests/unit/Db/WidgetTest.php
@@ -0,0 +1,34 @@
+ ['{"text":"Projects","showTitle":false,"level":2}', '{"level":2,"showTitle":false,"text":"Projects"}'],
+ 'no settings' => [null, '{}'],
+ 'not an object' => ['"text"', '{}'],
+ 'broken' => ['{broken', '{}'],
+ ];
+ }
+
+ /**
+ * @dataProvider storedSettings
+ */
+ public function testSettingsSerialiseAsAJsonObject(?string $stored, string $serialised): void {
+ $widget = new Widget();
+ $widget->setSettings($stored);
+
+ $this->assertSame($serialised, json_encode($widget->jsonSerialize()['settings']));
+ }
+}
diff --git a/tests/unit/Helper/IconHelperTest.php b/tests/unit/Helper/IconHelperTest.php
new file mode 100644
index 0000000000..37ef05b5c6
--- /dev/null
+++ b/tests/unit/Helper/IconHelperTest.php
@@ -0,0 +1,26 @@
+assertTrue(IconHelper::isValid('star'));
+ $this->assertTrue(IconHelper::isValid('home'));
+ }
+
+ public function testAnUnknownOrMalformedNameIsNotValid(): void {
+ foreach (['', 'no-such-icon', '../img/material/star', 'star.svg', 'star ', '📊'] as $name) {
+ $this->assertFalse(IconHelper::isValid($name), $name);
+ }
+ }
+}
diff --git a/tests/unit/Migration/Version2500Date20261022000000Test.php b/tests/unit/Migration/Version2500Date20261022000000Test.php
new file mode 100644
index 0000000000..762e128fb7
--- /dev/null
+++ b/tests/unit/Migration/Version2500Date20261022000000Test.php
@@ -0,0 +1,153 @@
+migration = new Version2500Date20261022000000($this->connectionAdapter);
+ }
+
+ protected function tearDown(): void {
+ $qb = $this->connectionAdapter->getQueryBuilder();
+ $qb->delete('tables_contexts_page')
+ ->where($qb->expr()->in('context_id', $qb->createNamedParameter([self::CONTEXT, self::OTHER_CONTEXT], IQueryBuilder::PARAM_INT_ARRAY)))
+ ->executeStatement();
+ parent::tearDown();
+ }
+
+ public function testTheUpgradeNamesExistingPagesAndASecondRunChangesNothing(): void {
+ $startPage = $this->insertPage(self::CONTEXT, 'startpage', null, null);
+ $secondStartPage = $this->insertPage(self::CONTEXT, 'startpage', null, null);
+ $sales = $this->insertPage(self::CONTEXT, 'grid', 'Sales', null);
+ $untitled = $this->insertPage(self::CONTEXT, 'grid', null, null);
+ $otherStartPage = $this->insertPage(self::OTHER_CONTEXT, 'startpage', null, null);
+
+ $this->migration->postSchemaChange(new NullOutput(), fn () => $this->schema(), []);
+
+ $expected = [
+ $startPage => 'dashboard',
+ $secondStartPage => 'page_' . $secondStartPage,
+ $sales => 'page_' . $sales,
+ $untitled => 'page_' . $untitled,
+ $otherStartPage => 'dashboard',
+ ];
+ $this->assertSame($expected, $this->names());
+
+ $this->migration->postSchemaChange(new NullOutput(), fn () => $this->schema(), []);
+ $this->assertSame($expected, $this->names());
+ }
+
+ public function testASecondSchemaRunChangesNothing(): void {
+ $schema = $this->schema();
+ $before = $this->describePageTable($schema);
+
+ $this->migration->changeSchema(new NullOutput(), fn () => $schema, []);
+
+ $this->assertSame($before, $this->describePageTable($schema));
+ $this->assertContains('technical_name', $before['columns']);
+ $this->assertContains('tables_ctx_page_name_uq', $before['indexes']);
+ }
+
+ public function testTheDatabaseAllowsPagesWithoutANameThatTheCodeNeverWrites(): void {
+ $first = $this->insertPage(self::CONTEXT, 'grid', null, null);
+ $second = $this->insertPage(self::CONTEXT, 'grid', null, null);
+
+ $this->assertSame([$first => null, $second => null], $this->names());
+ }
+
+ public static function backfills(): array {
+ return [
+ 'a start page whose name another page holds' => [
+ [[1, 'startpage', null], [2, 'grid', 'dashboard']],
+ [1 => 'page_1'],
+ ],
+ 'the lower id keeps dashboard whatever the row order' => [
+ [[9, 'startpage', null], [3, 'startpage', null]],
+ [3 => 'dashboard', 9 => 'page_9'],
+ ],
+ 'an empty name counts as none' => [
+ [[1, 'grid', ''], [2, 'grid', 'sales']],
+ [1 => 'page_1'],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider backfills
+ * @param list $pages id, type and technical name in one application
+ */
+ public function testBackfillNames(array $pages, array $expected): void {
+ $rows = array_map(static fn (array $page): array => ['id' => $page[0], 'context_id' => 10, 'page_type' => $page[1], 'technical_name' => $page[2]], $pages);
+
+ $this->assertSame($expected, Version2500Date20261022000000::backfillNames($rows));
+ }
+
+ private function insertPage(int $contextId, string $pageType, ?string $title, ?string $technicalName): int {
+ $qb = $this->connectionAdapter->getQueryBuilder();
+ $qb->insert('tables_contexts_page')
+ ->values([
+ 'context_id' => $qb->createNamedParameter($contextId, IQueryBuilder::PARAM_INT),
+ 'page_type' => $qb->createNamedParameter($pageType),
+ 'title' => $qb->createNamedParameter($title),
+ 'technical_name' => $qb->createNamedParameter($technicalName),
+ ])
+ ->executeStatement();
+ return $qb->getLastInsertId();
+ }
+
+ /**
+ * @return array technical name by page id
+ */
+ private function names(): array {
+ $qb = $this->connectionAdapter->getQueryBuilder();
+ $qb->select('id', 'technical_name')
+ ->from('tables_contexts_page')
+ ->where($qb->expr()->in('context_id', $qb->createNamedParameter([self::CONTEXT, self::OTHER_CONTEXT], IQueryBuilder::PARAM_INT_ARRAY)))
+ ->orderBy('id');
+ $result = $qb->executeQuery();
+ $names = [];
+ while ($row = $result->fetchAssociative()) {
+ $names[(int)$row['id']] = $row['technical_name'];
+ }
+ $result->closeCursor();
+ return $names;
+ }
+
+ private function schema(): SchemaWrapper {
+ return new SchemaWrapper($this->connection);
+ }
+
+ /**
+ * @return array{columns: list, indexes: list}
+ */
+ private function describePageTable(SchemaWrapper $schema): array {
+ $table = $schema->getTable('tables_contexts_page');
+ $columns = array_map(static fn ($column) => $column->getName(), $table->getColumns());
+ $indexes = array_map(static fn ($index) => $index->getName(), $table->getIndexes());
+ sort($columns);
+ sort($indexes);
+ return ['columns' => $columns, 'indexes' => $indexes];
+ }
+}
diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php
index 67b94c8cca..8cfec52e6e 100644
--- a/tests/unit/Service/ContextServiceTest.php
+++ b/tests/unit/Service/ContextServiceTest.php
@@ -19,6 +19,7 @@
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 OCA\Tables\Service\Support\AuditLogServiceInterface;
@@ -283,6 +284,90 @@ public function testDeleteLogsCriticalAction(): void {
$this->service->delete(9, 'alice');
}
+ public function testANewApplicationGetsAStartPageNamedDashboard(): void {
+ $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context {
+ $context->id = 7;
+ return $context;
+ });
+ $inserted = [];
+ $this->pageMapper->expects($this->once())->method('insert')
+ ->willReturnCallback(static function (Page $page) use (&$inserted): Page {
+ $page->id = 70;
+ $inserted[] = $page;
+ return $page;
+ });
+
+ $context = $this->service->create('Projects', 'star', '', [], 'user-1', 0);
+
+ self::assertSame(Page::TYPE_STARTPAGE, $inserted[0]->getPageType());
+ self::assertSame('dashboard', $inserted[0]->getTechnicalName());
+ self::assertSame('dashboard', $context->getPages()[70]['technicalName']);
+ }
+
+ public static function importedPages(): array {
+ $start = Page::TYPE_STARTPAGE;
+ $grid = Page::TYPE_GRID;
+ return [
+ 'free names are kept, the rest get the default' => [
+ [
+ ['id' => 1, 'page_type' => $start],
+ ['id' => 2, 'page_type' => $grid, 'technical_name' => 'sales'],
+ ['id' => 3, 'page_type' => $grid, 'technical_name' => 'sales'],
+ ['id' => 4, 'page_type' => $grid, 'technical_name' => 'Bad Name'],
+ ['id' => 5, 'page_type' => $grid],
+ ['id' => 6, 'page_type' => $grid, 'technical_name' => 'page_2'],
+ ],
+ [80 => [$start, 'dashboard'], 81 => [$grid, 'sales'], 82 => [$grid, 'page_82'], 83 => [$grid, 'page_83'], 84 => [$grid, 'page_84'], 85 => [$grid, 'page_85']],
+ ],
+ 'the start page leaves dashboard to the page exported with it' => [
+ [['id' => 1, 'page_type' => $start], ['id' => 2, 'page_type' => $grid, 'technical_name' => 'dashboard']],
+ [80 => [$start, 'page_80'], 81 => [$grid, 'dashboard']],
+ ],
+ 'only one start page and grid pages are imported' => [
+ [
+ ['id' => 1, 'pageType' => $start, 'technicalName' => 'home'],
+ ['id' => 2, 'page_type' => $start],
+ ['id' => 3, 'page_type' => 'chart'],
+ ['id' => 4],
+ 'not a page',
+ ['id' => 5, 'page_type' => $grid],
+ ],
+ [80 => [$start, 'home'], 81 => [$grid, 'page_81']],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider importedPages
+ * @param array $expected type and technical name by page id
+ */
+ public function testImportedPages(array $pages, array $expected): void {
+ $table = new Table();
+ $table->id = 40;
+ $table->setOwnership('user-1');
+ $this->contextMapper->method('insert')->willReturnCallback(static function (Context $context): Context {
+ $context->id = 8;
+ return $context;
+ });
+ $this->contextNodeRelMapper->method('insert')->willReturnArgument(0);
+ $inserted = [];
+ $pageId = 80;
+ $this->pageMapper->method('insert')->willReturnCallback(static function (Page $page) use (&$inserted, &$pageId): Page {
+ $page->id = $pageId++;
+ $inserted[$page->getId()] = [$page->getPageType(), $page->getTechnicalName()];
+ return $page;
+ });
+ $this->pageMapper->method('insertWithDefaultName')->willReturnCallback(static function (Page $page) use (&$inserted, &$pageId): Page {
+ $page->id = $pageId++;
+ $inserted[$page->getId()] = [$page->getPageType(), 'page_' . $page->getId()];
+ return $page;
+ });
+
+ $this->service->importContext($table, ['name' => 'Projects', 'nodes' => [['id' => 1, 'node_id' => 4, 'node_type' => 0]], 'pages' => $pages], 4);
+
+ self::assertSame($expected, $inserted);
+ }
+
private function buildPageContent(int $id, int $pageId, int $nodeRelId, int $order): PageContent {
$pageContent = new PageContent();
$pageContent->id = $id;
diff --git a/tests/unit/Service/ViewServiceTechnicalNameTest.php b/tests/unit/Service/ViewServiceTechnicalNameTest.php
new file mode 100644
index 0000000000..1fb10d9f9e
--- /dev/null
+++ b/tests/unit/Service/ViewServiceTechnicalNameTest.php
@@ -0,0 +1,37 @@
+ [str_repeat('a', 201), 'Technical name must not exceed 200 characters.'],
+ 'upper case' => ['Sales', 'Technical name must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.'],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidNames
+ */
+ public function testAnInvalidNameIsRefusedWithItsMessage(string $technicalName, string $message): void {
+ $this->expectException(BadRequestError::class);
+ $this->expectExceptionMessage($message);
+ $this->assertValid($technicalName);
+ }
+
+ private function assertValid(string $technicalName): void {
+ $method = new \ReflectionMethod(ViewService::class, 'assertTechnicalNameValid');
+ $method->invoke((new \ReflectionClass(ViewService::class))->newInstanceWithoutConstructor(), $technicalName);
+ }
+}
diff --git a/tests/unit/Validation/TechnicalNameValidatorTest.php b/tests/unit/Validation/TechnicalNameValidatorTest.php
new file mode 100644
index 0000000000..499cb28447
--- /dev/null
+++ b/tests/unit/Validation/TechnicalNameValidatorTest.php
@@ -0,0 +1,23 @@
+assertTrue(TechnicalNameValidator::isValid('open_cases_2'));
+ $this->assertTrue(TechnicalNameValidator::isValid(str_repeat('a', 200)));
+ foreach (['', 'Sales', '2026', '_sales', 'open-cases', 'café', 'has space', "sales\n", "sales\r\n", str_repeat('a', 201)] as $name) {
+ $this->assertFalse(TechnicalNameValidator::isValid($name), $name);
+ }
+ }
+}
diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php
index ce471574ad..f070cdbd48 100644
--- a/tests/unit/bootstrap.php
+++ b/tests/unit/bootstrap.php
@@ -20,6 +20,7 @@
require_once __DIR__ . '/../../../../tests/autoload.php';
require_once __DIR__ . '/Database/DatabaseTestCase.php';
+require_once __DIR__ . '/Database/DbErrorStub.php';
require_once __DIR__ . '/Db/Row2MapperTestDependencies.php';