@@ -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
*/