diff --git a/appinfo/info.xml b/appinfo/info.xml index 4f05ced258..b59750d550 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.2.0 + 2.2.1 AGPL-3.0-or-later Nextcloud GmbH and Nextcloud contributors Tables diff --git a/lib/Constants/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index f991af4525..f7d512889f 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -16,4 +16,5 @@ enum ViewUpdatableParameters: string { case SORT = 'sort'; case FILTER = 'filter'; case COLUMN_SETTINGS = 'columns'; + case TECHNICAL_NAME = 'technicalName'; } diff --git a/lib/Db/View.php b/lib/Db/View.php index 34783f76ef..45534cec91 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -25,6 +25,8 @@ * @method setId(int $id) * @method getTitle(): string * @method setTitle(string $title) + * @method getTechnicalName(): string + * @method setTechnicalName(?string $technicalName) * @method getTableId(): int * @method setTableId(int $tableId) * @method getColumns(): string @@ -64,6 +66,7 @@ */ class View extends EntitySuper implements JsonSerializable { protected ?string $title = null; + protected ?string $technicalName = null; protected ?int $tableId = null; protected ?string $createdBy = null; protected ?string $createdAt = null; @@ -183,6 +186,7 @@ public function jsonSerialize(): array { 'id' => $this->id, 'tableId' => ($this->tableId || $this->tableId === 0) ? $this->tableId : -1, 'title' => $this->title ?: '', + 'technicalName' => $this->technicalName, 'description' => $this->description, 'emoji' => $this->emoji, 'ownership' => $this->ownership ?: '', diff --git a/lib/Migration/Version2011Date20260428000000.php b/lib/Migration/Version2011Date20260428000000.php new file mode 100644 index 0000000000..548dc162ca --- /dev/null +++ b/lib/Migration/Version2011Date20260428000000.php @@ -0,0 +1,84 @@ +connection = $connection; + } + + #[Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('tables_views')) { + return null; + } + + $table = $schema->getTable('tables_views'); + if (!$table->hasColumn('technical_name')) { + $table->addColumn('technical_name', Types::STRING, [ + 'notnull' => false, + 'length' => 200, + ]); + } + + if (!$table->hasIndex('tables_views_table_tech_name_uq')) { + $table->addUniqueIndex(['table_id', 'technical_name'], 'tables_views_table_tech_name_uq'); + } + + return $schema; + } + + #[Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + $selectQb = $this->connection->getQueryBuilder(); + $selectQb->select('id') + ->from('tables_views') + ->where( + $selectQb->expr()->orX( + $selectQb->expr()->isNull('technical_name'), + $selectQb->expr()->eq('technical_name', $selectQb->createNamedParameter('')), + ) + ); + + $result = $selectQb->executeQuery(); + $updatedCount = 0; + + try { + while ($row = $result->fetchAssociative()) { + $viewId = (int)$row['id']; + + $updateQb = $this->connection->getQueryBuilder(); + $updateQb->update('tables_views') + ->set('technical_name', $updateQb->createNamedParameter('view_' . $viewId, IQueryBuilder::PARAM_STR)) + ->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($viewId, IQueryBuilder::PARAM_INT))); + + $updatedCount += $updateQb->executeStatement(); + } + } finally { + $result->closeCursor(); + } + + $output->info('Version2011Date20260428000000: backfilled technical_name for ' . $updatedCount . ' views.'); + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 3e01ef9789..7fc2686fd5 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -24,6 +24,7 @@ class ViewUpdateInput { public function __construct( protected readonly ?Title $title = null, + protected readonly ?string $technicalName = null, protected readonly ?string $description = null, protected readonly ?Emoji $emoji = null, protected readonly ?ColumnSettings $columnSettings = null, @@ -36,6 +37,9 @@ public function updateDetail(): Generator { if ($this->title) { yield ViewUpdatableParameters::TITLE => $this->title; } + if ($this->technicalName !== null) { + yield ViewUpdatableParameters::TECHNICAL_NAME => $this->technicalName; + } if ($this->description) { yield ViewUpdatableParameters::DESCRIPTION => $this->description; } @@ -58,6 +62,7 @@ public function updateDetail(): Generator { * title?: string, * emoji?: string, * description?: string, + * technicalName?: string, * columns?: list, * columnSettings?: list, * sort?: list, @@ -82,6 +87,7 @@ public static function fromInputArray(array $data): self { return new self( title: ($data['title'] ?? null) ? new Title($data['title']) : null, + technicalName: $data['technicalName'] ?? null, description: $data['description'] ?? null, emoji: ($data['emoji'] ?? null) ? new Emoji($data['emoji']) : null, columnSettings: ($data['columnSettings'] ?? null) ? ColumnSettings::createViewSettingsFromInputArray($data['columnSettings']) : null, diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 1dd4d5f6a8..db961ae63d 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -16,6 +16,7 @@ * @psalm-type TablesView = array{ * id: int, * title: string, + * technicalName: string|null, * emoji: string|null, * tableId: int, * ownership: string, diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index c79da495a9..f9ef214247 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -19,6 +19,7 @@ use OCA\Tables\Db\Table; use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; +use OCA\Tables\Errors\BadRequestError; use OCA\Tables\Errors\InternalError; use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; @@ -213,6 +214,9 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use $item->setLastEditBy($userId); $item->setCreatedAt($time->format('Y-m-d H:i:s')); $item->setLastEditAt($time->format('Y-m-d H:i:s')); + if ($item->getTechnicalName() !== null) { + $this->assertTechnicalNameUnique($table->getId(), $item->getTechnicalName()); + } // ownership is not stored with the record, but it might be necessary upon // further interaction with the view in the running process, as the instance // is cached now. The ownership is always inherited from the table. @@ -223,6 +227,15 @@ public function create(string $title, ?string $emoji, Table $table, ?string $use $this->logger->error($e->getMessage()); throw new InternalError($e->getMessage()); } + if ($newItem->getTechnicalName() === null || $newItem->getTechnicalName() === '') { + $newItem->setTechnicalName($this->buildDefaultTechnicalName($newItem->getId())); + try { + $newItem = $this->mapper->update($newItem); + } catch (\OCP\DB\Exception $e) { + $this->logger->error($e->getMessage()); + throw new InternalError($e->getMessage()); + } + } return $newItem; } @@ -250,6 +263,10 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b $this->assertInputColumnsAreValid($view, $userId, $value); } + if ($parameter === ViewUpdatableParameters::TECHNICAL_NAME) { + $this->assertTechnicalNameUnique($view->getTableId(), $value, $view->getId()); + } + if ($value instanceof JsonSerializable) { $insertableValue = json_encode($value); } @@ -266,6 +283,8 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b $this->enhanceView($view, $userId); } return $view; + } catch (BadRequestError $e) { + throw $e; } catch (InvalidArgumentException $e) { throw $e; } catch (Exception $e) { @@ -603,6 +622,7 @@ public function importView(int $tableId, array $view, string $userId): void { $item->setTableId($tableId); $item->setTitle($view['title']); $item->setEmoji($view['emoji']); + $item->setTechnicalName($view['technicalName'] ?? null); $item->setCreatedBy($userId); $item->setCreatedAt($view['createdAt']); $item->setLastEditBy($userId); @@ -613,9 +633,42 @@ public function importView(int $tableId, array $view, string $userId): void { $item->setFilter(json_encode($view['filter'])); try { $this->mapper->insert($item); + if ($item->getTechnicalName() === null || $item->getTechnicalName() === '') { + $item->setTechnicalName($this->buildDefaultTechnicalName($item->getId())); + $this->mapper->update($item); + } + } catch (BadRequestError $e) { + throw $e; } catch (\Exception $e) { $this->logger->error('userMigrationImport insert error: ' . $e->getMessage()); throw new InternalError('userMigrationImport insert error: ' . $e->getMessage()); } } + + private function buildDefaultTechnicalName(int $viewId): string { + return 'view_' . $viewId; + } + + /** + * @throws BadRequestError + * @throws InternalError + */ + private function assertTechnicalNameUnique(int $tableId, string $technicalName, ?int $excludeCurrentViewId = null): void { + try { + $views = $this->mapper->findAll($tableId); + } catch (\OCP\DB\Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw new InternalError(get_class($this) . ' - ' . __FUNCTION__ . ': ' . $e->getMessage()); + } + + foreach ($views as $view) { + if ($excludeCurrentViewId !== null && $view->getId() === $excludeCurrentViewId) { + continue; + } + + if ($view->getTechnicalName() === $technicalName) { + throw new BadRequestError('Technical name must be unique in the table.'); + } + } + } } diff --git a/openapi.json b/openapi.json index 1b05204d98..8351bd4175 100644 --- a/openapi.json +++ b/openapi.json @@ -899,6 +899,7 @@ "required": [ "id", "title", + "technicalName", "emoji", "tableId", "ownership", @@ -926,6 +927,10 @@ "title": { "type": "string" }, + "technicalName": { + "type": "string", + "nullable": true + }, "emoji": { "type": "string", "nullable": true diff --git a/src/modules/modals/ViewSettings.vue b/src/modules/modals/ViewSettings.vue index fef14d5f89..feded6b067 100644 --- a/src/modules/modals/ViewSettings.vue +++ b/src/modules/modals/ViewSettings.vue @@ -61,6 +61,38 @@ :generated-sort="viewSetting ? generatedView.sort : null" :columns="allColumns" /> + + +
+
+ + + {{ t('tables', 'Show advanced settings') }} + +
+
+
+
+ {{ t('tables', 'Technical name') }} +
+
+ +
+
+
+
+ +

{{ t('tables', 'Changing the technical name affects integrations and API. Make sure to update your services accordingly.') }}

+
+
+
+
@@ -79,6 +111,8 @@ @@ -380,4 +438,12 @@ export default { position: sticky; bottom: 0; } + +.error { + color: var(--color-error); +} + +.error input { + border-color: var(--color-error); +} diff --git a/src/shared/utils/columnUtils.js b/src/shared/utils/columnUtils.js new file mode 100644 index 0000000000..be1af343b4 --- /dev/null +++ b/src/shared/utils/columnUtils.js @@ -0,0 +1,27 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * @param {string|null|undefined} technicalName + * @return {string|null} + */ +export function normalizeTechnicalName(technicalName) { + const normalized = technicalName?.trim() + return normalized === '' ? null : normalized +} + +/** + * returns true if the technical name is valid including null value. + * + * @param {string|null|undefined} technicalName + * @return {boolean} + */ +export function isTechnicalNameValid(technicalName) { + const normalized = normalizeTechnicalName(technicalName) + if (normalized === null) { + return true + } + return /^[a-z][a-z0-9_]*$/.test(normalized) +} diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index ae8deca03c..f83a876ba3 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1172,6 +1172,7 @@ export type components = { /** Format: int64 */ readonly id: number; readonly title: string; + readonly technicalName: string | null; readonly emoji: string | null; /** Format: int64 */ readonly tableId: number; diff --git a/tests/integration/features/APIv1.feature b/tests/integration/features/APIv1.feature index 6354e13f6d..2f6385f3e8 100644 --- a/tests/integration/features/APIv1.feature +++ b/tests/integration/features/APIv1.feature @@ -395,7 +395,7 @@ Feature: APIv1 Then the reported status is "200" When user "participant1" tries to set permission "manage" to 1 for context "c1" and user "participant2" Then the reported status is "403" - + @api1 @contexts @contexts-sharing Scenario: Share an inaccessible context Given table "Table 1 via api v2" with emoji "👋" exists for user "participant1" as "t1" via v2 @@ -552,3 +552,45 @@ Feature: APIv1 Then view "general-tasks" has exactly the following rows | task | | New general task | + + @api1 @views @technical-name + Scenario: Create and update view with technical name + Given table "View technical name test" with emoji "🔧" exists for user "participant1" as "tech-name-table" + When user "participant1" create view "Customer View" with emoji "👥" and technical name "customer_view" for "tech-name-table" as "customer-view" + Then table "tech-name-table" has the following views for user "participant1" + | Customer View | + When user "participant1" update view "customer-view" with title "Updated Customer View", emoji "👤" and technical name "updated_customer_view" + Then table "tech-name-table" has the following views for user "participant1" + | Updated Customer View | + When user "participant1" deletes view "customer-view" + + @api1 @views @technical-name + Scenario: Create view with invalid technical name should fail + Given table "View invalid name test" with emoji "❌" exists for user "participant1" as "invalid-name-table" + When user "participant1" attempts to create view "Invalid View" with emoji "🚫" and technical name "InvalidName" for "invalid-name-table" as "invalid-view" + Then the reported status is "400" + + @api1 @views @technical-name + Scenario: Create view with valid technical name patterns + Given table "View patterns test" with emoji "🔢" exists for user "participant1" as "patterns-table" + When user "participant1" create view "Valid Pattern 1" with emoji "✅" and technical name "valid_view" for "patterns-table" as "valid-pattern-1" + When user "participant1" create view "Valid Pattern 2" with emoji "✅" and technical name "view123" for "patterns-table" as "valid-pattern-2" + When user "participant1" create view "Valid Pattern 3" with emoji "✅" and technical name "view_with_underscores" for "patterns-table" as "valid-pattern-3" + When user "participant1" create view "Valid Pattern 4" with emoji "✅" and technical name "view_123_abc" for "patterns-table" as "valid-pattern-4" + Then table "patterns-table" has the following views for user "participant1" + | Valid Pattern 1 | + | Valid Pattern 2 | + | Valid Pattern 3 | + | Valid Pattern 4 | + When user "participant1" deletes view "valid-pattern-1" + When user "participant1" deletes view "valid-pattern-2" + When user "participant1" deletes view "valid-pattern-3" + When user "participant1" deletes view "valid-pattern-4" + + @api1 @views @technical-name + Scenario: Update view with invalid technical name should fail + Given table "View update invalid test" with emoji "❌" exists for user "participant1" as "update-invalid-table" + When user "participant1" create view "Update Test View" with emoji "🔄" for "update-invalid-table" as "update-test-view" + When user "participant1" attempts to update view "update-test-view" with title "Updated View", emoji "🔄" and technical name "InvalidName" + Then the reported status is "400" + When user "participant1" deletes view "update-test-view" diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php index ea4f23cd16..9f8caf75c4 100644 --- a/tests/integration/features/bootstrap/FeatureContext.php +++ b/tests/integration/features/bootstrap/FeatureContext.php @@ -672,22 +672,29 @@ public function tableViews(string $tableName, string $user, ?TableNode $body = n /** * @Given user :user create view :title with emoji :emoji for :tableName as :viewName + * @Given user :user create view :title with emoji :emoji and technical name :technicalName for :tableName as :viewName * * @param string $user * @param string $title * @param string $tableName * @param string $viewName * @param string|null $emoji + * @param string|null $technicalName */ - public function createView(string $user, string $title, string $tableName, string $viewName, ?string $emoji = null): void { + public function createView(string $user, string $title, string $tableName, string $viewName, ?string $emoji = null, ?string $technicalName = null): void { $this->setCurrentUser($user); + $data = [ + 'title' => $title, + 'emoji' => $emoji + ]; + if ($technicalName !== null) { + $data['technicalName'] = $technicalName; + } + $this->sendRequest( 'POST', '/apps/tables/api/1/tables/' . $this->tableIds[$tableName] . '/views', - [ - 'title' => $title, - 'emoji' => $emoji - ] + $data ); $newItem = $this->getDataFromResponse($this->response); @@ -701,6 +708,9 @@ public function createView(string $user, string $title, string $tableName, strin Assert::assertEquals(200, $this->response->getStatusCode()); Assert::assertEquals($newItem['title'], $title); Assert::assertEquals($newItem['emoji'], $emoji); + if ($technicalName !== null) { + Assert::assertEquals($newItem['technicalName'], $technicalName); + } $this->sendRequest( 'GET', @@ -712,6 +722,9 @@ public function createView(string $user, string $title, string $tableName, strin Assert::assertEquals(200, $this->response->getStatusCode()); Assert::assertEquals($itemToVerify['title'], $title); Assert::assertEquals($itemToVerify['emoji'], $emoji); + if ($technicalName !== null) { + Assert::assertEquals($itemToVerify['technicalName'], $technicalName); + } } /** @@ -883,19 +896,24 @@ public function applySortToTable(string $tableName, TableNode $sortOrder): void /** * @When user :user update view :viewName with title :title and emoji :emoji + * @When user :user update view :viewName with title :title, emoji :emoji and technical name :technicalName * * @param string $user * @param string $viewName * @param string $title * @param string|null $emoji + * @param string|null $technicalName */ - public function updateView(string $user, string $viewName, string $title, ?string $emoji): void { + public function updateView(string $user, string $viewName, string $title, ?string $emoji, ?string $technicalName = null): void { $this->setCurrentUser($user); $data = ['title' => $title]; if ($emoji !== null) { $data['emoji'] = $emoji; } + if ($technicalName !== null) { + $data['technicalName'] = $technicalName; + } $this->sendUpdateViewRequest($viewName, $data); $updatedItem = $this->getDataFromResponse($this->response); @@ -903,6 +921,9 @@ public function updateView(string $user, string $viewName, string $title, ?strin Assert::assertEquals(200, $this->response->getStatusCode()); Assert::assertEquals($updatedItem['title'], $title); Assert::assertEquals($updatedItem['emoji'], $emoji); + if ($technicalName !== null) { + Assert::assertEquals($updatedItem['technicalName'], $technicalName); + } $this->sendRequest( 'GET', @@ -913,6 +934,9 @@ public function updateView(string $user, string $viewName, string $title, ?strin Assert::assertEquals(200, $this->response->getStatusCode()); Assert::assertEquals($itemToVerify['title'], $title); Assert::assertEquals($itemToVerify['emoji'], $emoji); + if ($technicalName !== null) { + Assert::assertEquals($itemToVerify['technicalName'], $technicalName); + } } /** @@ -1084,6 +1108,43 @@ public function userAttemptsToShareTheTableWithUser(string $user, string $receiv ); } + /** + * @When user :user attempts to create view :title with emoji :emoji and technical name :technicalName for :tableName as :viewName + */ + public function userAttemptsToCreateViewWithTechnicalName(string $user, string $title, string $tableName, string $viewName, ?string $emoji = null, ?string $technicalName = null): void { + $this->setCurrentUser($user); + $data = [ + 'title' => $title, + 'emoji' => $emoji + ]; + if ($technicalName !== null) { + $data['technicalName'] = $technicalName; + } + + $this->sendRequest( + 'POST', + '/apps/tables/api/1/tables/' . $this->tableIds[$tableName] . '/views', + $data + ); + } + + /** + * @When user :user attempts to update view :viewName with title :title, emoji :emoji and technical name :technicalName + */ + public function userAttemptsToUpdateViewWithTechnicalName(string $user, string $viewName, string $title, ?string $emoji = null, ?string $technicalName = null): void { + $this->setCurrentUser($user); + + $data = ['title' => $title]; + if ($emoji !== null) { + $data['emoji'] = $emoji; + } + if ($technicalName !== null) { + $data['technicalName'] = $technicalName; + } + + $this->sendUpdateViewRequest($viewName, $data); + } + /** * @Then user :initiator shares :nodeType :nodeAlias with :shareType :recipient */