@@ -172,6 +174,7 @@ export default {
numberPrefix: '',
numberSuffix: '',
selectedViews: [],
+ technicalName: '',
mandatory: false,
numberDefault: null,
numberMin: 0,
@@ -197,6 +200,7 @@ export default {
typeMissingError: false,
widthInvalidError: false,
titleMissingError: false,
+ technicalNameInvalidError: false,
typeOptions: [
{ id: 'text', label: t('tables', 'Text') },
{ id: 'text-link', label: t('tables', 'Link') },
@@ -292,6 +296,9 @@ export default {
if (!this.column.title) {
showInfo(t('tables', 'Please insert a title for the new column.'))
this.titleMissingError = true
+ } else if (!this.isTechnicalNameValid()) {
+ showError(t('tables', 'Cannot save column. Technical name must start with a lowercase letter and only contain lowercase letters, numbers, and underscores.'))
+ this.technicalNameInvalidError = true
} else if (this.column.customSettings?.width
&& (this.column.customSettings?.width < COLUMN_WIDTH_MIN || this.column.customSettings?.width > COLUMN_WIDTH_MAX)) {
showError(t('tables', 'Cannot save column. Column width must be between {min} and {max}.', { min: COLUMN_WIDTH_MIN, max: COLUMN_WIDTH_MAX }))
@@ -321,10 +328,12 @@ export default {
this.$emit('close')
},
prepareSubmitData() {
+ const technicalName = this.normalizeTechnicalName(this.column.technicalName)
const data = {
type: this.column.type,
subtype: this.column.subtype,
title: this.column.title,
+ technicalName,
description: this.column.description,
selectedViewIds: this.column.selectedViews.map(view => view.id),
mandatory: this.column.mandatory,
@@ -398,6 +407,7 @@ export default {
type: this.column.type,
subtype: this.column.subtype,
title: this.column.title,
+ technicalName: this.column.technicalName,
description: this.column.description,
selectedViews: this.column.selectedViews,
mandatory: this.column.mandatory,
@@ -424,6 +434,7 @@ export default {
}
if (mainForm) {
this.column.title = ''
+ this.column.technicalName = ''
this.column.description = ''
this.column.mandatory = false
}
@@ -435,9 +446,22 @@ export default {
this.column.selectedViews = []
}
this.titleMissingError = false
+ this.technicalNameInvalidError = false
this.widthInvalidError = false
this.typeMissingError = false
},
+ normalizeTechnicalName(technicalName) {
+ const normalized = technicalName?.trim()
+ return normalized === '' ? null : normalized
+ },
+ isTechnicalNameValid() {
+ const technicalName = this.normalizeTechnicalName(this.column.technicalName)
+ if (technicalName === null) {
+ return true
+ }
+
+ return /^[a-z][a-z0-9_]*$/.test(technicalName)
+ },
},
}
diff --git a/src/modules/modals/EditColumn.vue b/src/modules/modals/EditColumn.vue
index e5981514a4..76e4e628e7 100644
--- a/src/modules/modals/EditColumn.vue
+++ b/src/modules/modals/EditColumn.vue
@@ -12,10 +12,12 @@
@@ -125,6 +127,7 @@ export default {
editColumn: JSON.parse(JSON.stringify(this.column)),
deleteId: null,
editErrorTitle: false,
+ technicalNameInvalidError: false,
widthInvalidError: false,
canSave: true, // used to avoid saving an incorrect config
}
@@ -171,6 +174,12 @@ export default {
return
}
+ if (!this.isTechnicalNameValid()) {
+ showError(t('tables', 'Cannot save column. Technical name must start with a lowercase letter and only contain lowercase letters, numbers, and underscores.'))
+ this.technicalNameInvalidError = true
+ return
+ }
+
if (this.editColumn.customSettings?.width
&& (this.editColumn.customSettings?.width < COLUMN_WIDTH_MIN || this.editColumn.customSettings?.width > COLUMN_WIDTH_MAX)) {
showError(t('tables', 'Cannot save column. Column width must be between {min} and {max}.', { min: COLUMN_WIDTH_MIN, max: COLUMN_WIDTH_MAX }))
@@ -187,6 +196,7 @@ export default {
this.editColumn = null
this.deleteId = null
this.editErrorTitle = false
+ this.technicalNameInvalidError = false
this.widthInvalidError = false
},
async updateLocalColumn() {
@@ -203,6 +213,7 @@ export default {
delete data.createdBy
delete data.lastEditAt
delete data.lastEditBy
+ data.technicalName = this.normalizeTechnicalName(data.technicalName)
data.customSettings = { width: data.customSettings.width }
console.debug('this column data will be send', data)
const res = await this.updateColumn({
@@ -216,6 +227,18 @@ export default {
showSuccess(t('tables', 'The column "{column}" was updated.', { column: this.editColumn.title }))
}
},
+ normalizeTechnicalName(technicalName) {
+ const normalized = technicalName?.trim()
+ return normalized === '' ? null : normalized
+ },
+ isTechnicalNameValid() {
+ const technicalName = this.normalizeTechnicalName(this.editColumn.technicalName)
+ if (technicalName === null) {
+ return false
+ }
+
+ return /^[a-z][a-z0-9_]*$/.test(technicalName)
+ },
},
}
diff --git a/src/shared/components/ncTable/mixins/columnClass.js b/src/shared/components/ncTable/mixins/columnClass.js
index 87604acb69..d055f4e032 100644
--- a/src/shared/components/ncTable/mixins/columnClass.js
+++ b/src/shared/components/ncTable/mixins/columnClass.js
@@ -18,6 +18,7 @@ export class AbstractColumn {
this.mandatory = data.mandatory
this.tableId = data.tableId
this.title = data.title
+ this.technicalName = data.technicalName
this.description = data.description
this.createdByDisplayName = data.createdByDisplayName
this.viewColumnInformation = data.viewColumnInformation
diff --git a/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue b/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue
index 030a878a1e..274e553167 100644
--- a/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue
+++ b/src/shared/components/ncTable/partials/columnTypePartials/forms/MainForm.vue
@@ -12,6 +12,17 @@
+
+
+ {{ t('tables', 'Technical name') }}
+
+
+
+
+
{{ t('tables', 'Description') }}
@@ -98,6 +109,10 @@ export default {
type: Boolean,
default: null,
},
+ technicalName: {
+ type: String,
+ default: null,
+ },
selectedViews: {
type: Array,
default: null,
@@ -110,6 +125,10 @@ export default {
type: Boolean,
default: false,
},
+ technicalNameInvalidError: {
+ type: Boolean,
+ default: false,
+ },
editColumn: {
type: Boolean,
default: false,
@@ -137,6 +156,10 @@ export default {
get() { return this.description },
set(description) { this.$emit('update:description', description) },
},
+ localTechnicalName: {
+ get() { return this.technicalName },
+ set(technicalName) { this.$emit('update:technicalName', technicalName) },
+ },
localMandatory: {
get() { return this.mandatory },
set(mandatory) { this.$emit('update:mandatory', mandatory) },
diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts
index 14fff1e485..545b813cbc 100644
--- a/src/types/openapi/openapi.ts
+++ b/src/types/openapi/openapi.ts
@@ -914,6 +914,7 @@ export type components = {
/** Format: int64 */
readonly id: number;
readonly title: string;
+ readonly technicalName: string;
/** Format: int64 */
readonly tableId: number;
readonly createdBy: string;
@@ -1019,6 +1020,7 @@ export type components = {
/** Format: int64 */
readonly id: number;
readonly title: string;
+ readonly technicalName: string;
readonly createdAt: string;
readonly lastEditAt: string;
readonly type: string;
@@ -1074,6 +1076,13 @@ export type components = {
readonly columnId: number;
readonly value: Record;
} | null;
+ readonly dataByAlias: {
+ readonly [key: string]: {
+ /** Format: int64 */
+ readonly columnId: number;
+ readonly value: Record;
+ };
+ };
};
readonly Row: {
/** Format: int64 */
@@ -1089,6 +1098,13 @@ export type components = {
readonly columnId: number;
readonly value: Record;
} | null;
+ readonly dataByAlias: {
+ readonly [key: string]: {
+ /** Format: int64 */
+ readonly columnId: number;
+ readonly value: Record;
+ };
+ };
};
readonly Share: {
/** Format: int64 */
@@ -1146,6 +1162,13 @@ export type components = {
readonly ownership: string;
readonly ownerDisplayName: string | null;
readonly createdBy: string;
+ readonly dataByAlias: {
+ readonly [key: string]: {
+ /** Format: int64 */
+ readonly columnId: number;
+ readonly value: Record;
+ };
+ };
readonly createdAt: string;
readonly lastEditBy: string;
readonly lastEditAt: string;
@@ -2666,6 +2689,8 @@ export interface operations {
readonly "application/json": {
/** @description Title */
readonly title: string;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/**
* @description Column main type
* @enum {string}
@@ -2926,6 +2951,8 @@ export interface operations {
readonly viewId?: number | null;
/** @description Title */
readonly title: string;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/**
* @description Column main type
* @enum {string}
@@ -3182,6 +3209,8 @@ export interface operations {
readonly "application/json": {
/** @description Title */
readonly title?: string | null;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/** @description Column sub type */
readonly subtype?: string | null;
/** @description Is the column mandatory */
@@ -5156,6 +5185,8 @@ export interface operations {
readonly baseNodeId: number;
/** @description Title */
readonly title: string;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/**
* Format: double
* @description Default value for new rows
@@ -5316,6 +5347,8 @@ export interface operations {
readonly baseNodeId: number;
/** @description Title */
readonly title: string;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/** @description Default */
readonly textDefault?: string | null;
/** @description Allowed regex pattern */
@@ -5466,6 +5499,8 @@ export interface operations {
readonly baseNodeId: number;
/** @description Title */
readonly title: string;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/** @description Json array{id: int, label: string} with options that can be selected, eg [{"id": 1, "label": "first"},{"id": 2, "label": "second"}] */
readonly selectionOptions: string;
/** @description Json int|list for default selected option(s), eg 5 or ["1", "8"] */
@@ -5606,6 +5641,8 @@ export interface operations {
readonly baseNodeId: number;
/** @description Title */
readonly title: string;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/**
* @description For a subtype 'date' you can set 'today'. For a main type or subtype 'time' you can set to 'now'.
* @enum {string|null}
@@ -5747,6 +5784,8 @@ export interface operations {
readonly baseNodeId: number;
/** @description Title */
readonly title: string;
+ /** @description Technical name of the column */
+ readonly technicalName?: string | null;
/** @description Json array{id: string, type: int}, eg [{"id": "admin", "type": 0}, {"id": "user1", "type": 0}] */
readonly usergroupDefault?: string | null;
/**
diff --git a/tests/integration/features/APIv1.feature b/tests/integration/features/APIv1.feature
index 0c37c8beb6..f53a737955 100644
--- a/tests/integration/features/APIv1.feature
+++ b/tests/integration/features/APIv1.feature
@@ -522,3 +522,23 @@ Feature: APIv1
Then view "general-tasks" has exactly the following rows
| task |
| New general task |
+
+ @api1 @columns @rows
+ Scenario: Column technicalName is returned and dataByAlias maps alias to row values
+ Given table "Alias test" with emoji "🔖" exists for user "participant1" as "base1"
+ Then column "name" exists with following properties
+ | type | text |
+ | subtype | line |
+ | mandatory | 0 |
+ | technicalName | name |
+ Then column "score" exists with following properties
+ | type | number |
+ | mandatory | 0 |
+ | technicalName | score |
+ Then row exists with following values
+ | name | check1 |
+ | score | 42 |
+ Then the last created row has the following dataByAlias
+ | name | check1 |
+ | score | 42 |
+ Then user "participant1" deletes table with keyword "Alias test"
diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php
index 1a8f45b9c7..9624aab08c 100644
--- a/tests/integration/features/bootstrap/FeatureContext.php
+++ b/tests/integration/features/bootstrap/FeatureContext.php
@@ -2890,4 +2890,27 @@ public function viewHasExactRows(string $viewName, TableNode $expectedRows): voi
);
}
}
+
+ /**
+ * @Then the last created row has the following dataByAlias
+ *
+ * @param TableNode $table
+ */
+ public function theLastCreatedRowHasTheFollowingDataByAlias(TableNode $table): void {
+ $this->sendRequest(
+ 'GET',
+ '/apps/tables/api/1/rows/' . $this->rowId,
+ );
+
+ $row = $this->getDataFromResponse($this->response);
+ Assert::assertEquals(200, $this->response->getStatusCode());
+ $dataByAlias = $row['dataByAlias'];
+
+ foreach ($table->getRows() as [$alias, $expectedValue]) {
+ Assert::assertArrayHasKey($alias, $dataByAlias, "Alias '$alias' not found in dataByAlias");
+ Assert::assertEquals($expectedValue, (string)$dataByAlias[$alias]['value'], "Value mismatch for alias '$alias'");
+ $columnId = $this->collectionManager->getByAlias('column', $alias)['id'];
+ Assert::assertEquals($columnId, $dataByAlias[$alias]['columnId'], "columnId mismatch for alias '$alias'");
+ }
+ }
}
diff --git a/tests/unit/Service/LegacyRowMapperTest.php b/tests/unit/Service/LegacyRowMapperTest.php
index bce9a926ce..7795782d36 100644
--- a/tests/unit/Service/LegacyRowMapperTest.php
+++ b/tests/unit/Service/LegacyRowMapperTest.php
@@ -137,6 +137,8 @@ public function testMigrateLegacyRow() {
$data2 = $row2->getData();
self::assertTrue($data === $data2);
- self::assertTrue($legacyRow->jsonSerialize() === $row2->jsonSerialize());
+ $row2Serialized = $row2->jsonSerialize();
+ unset($row2Serialized['dataByAlias']);
+ self::assertTrue($legacyRow->jsonSerialize() === $row2Serialized);
}
}
diff --git a/tests/unit/Validation/ColumnDtoValidatorTest.php b/tests/unit/Validation/ColumnDtoValidatorTest.php
new file mode 100644
index 0000000000..921d28389d
--- /dev/null
+++ b/tests/unit/Validation/ColumnDtoValidatorTest.php
@@ -0,0 +1,139 @@
+validator = new ColumnDtoValidator();
+ }
+
+ private function dto(?string $technicalName, ?int $textMaxLength = null, ?float $numberMin = null, ?float $numberMax = null): ColumnDto {
+ return new ColumnDto(
+ technicalName: $technicalName,
+ textMaxLength: $textMaxLength,
+ numberMin: $numberMin,
+ numberMax: $numberMax,
+ );
+ }
+
+ public function testNullTechnicalNameIsAccepted(): void {
+ $this->expectNotToPerformAssertions();
+ $this->validator->validate($this->dto(null));
+ }
+
+ public function testEmptyTechnicalNameIsRejected(): void {
+ $this->expectException(BadRequestError::class);
+ $this->expectExceptionMessageMatches('/must not be empty/i');
+ $this->validator->validate($this->dto(''));
+ }
+
+ public function testTechnicalNameExactly200CharsIsAccepted(): void {
+ $this->expectNotToPerformAssertions();
+ $this->validator->validate($this->dto('tes' . str_repeat('t', 196)));
+ }
+
+ public function testTechnicalNameOver200CharsIsRejected(): void {
+ $this->expectException(BadRequestError::class);
+ $this->expectExceptionMessageMatches('/maximum.*200/i');
+ $this->validator->validate($this->dto('test' . str_repeat('t', 197)));
+ }
+
+ /**
+ * @dataProvider reservedNameProvider
+ */
+ #[DataProvider('reservedNameProvider')]
+ public function testReservedTechnicalNamesAreRejected(string $name): void {
+ $this->expectException(BadRequestError::class);
+ $this->expectExceptionMessageMatches('/reserved/i');
+ $this->validator->validate($this->dto($name));
+ }
+
+ public static function reservedNameProvider(): array {
+ return [
+ ['id'],
+ ['created_by'],
+ ['created_at'],
+ ['last_edit_by'],
+ ['last_edit_at'],
+ ['data'],
+ ];
+ }
+
+ /**
+ * @dataProvider invalidFormatProvider
+ */
+ #[DataProvider('invalidFormatProvider')]
+ public function testInvalidFormatIsRejected(string $name): void {
+ $this->expectException(BadRequestError::class);
+ $this->expectExceptionMessageMatches('/start with a letter|lowercase|underscores/i');
+ $this->validator->validate($this->dto($name));
+ }
+
+ public static function invalidFormatProvider(): array {
+ return [
+ ['1starts_with_digit'],
+ ['_starts_with_underscore'],
+ ['Has_Uppercase'],
+ ['has space'],
+ ['has-dash'],
+ ['has.dot'],
+ ];
+ }
+
+ /**
+ * @dataProvider validFormatProvider
+ */
+ #[DataProvider('validFormatProvider')]
+ public function testValidFormatIsAccepted(string $name): void {
+ $this->expectNotToPerformAssertions();
+ $this->validator->validate($this->dto($name));
+ }
+
+ public static function validFormatProvider(): array {
+ return [
+ ['customer_name'],
+ ['field1'],
+ ['abc'],
+ ['a123_xyz'],
+ ['column_42'],
+ ];
+ }
+
+ public function testNegativeTextMaxLengthIsRejected(): void {
+ $this->expectException(BadRequestError::class);
+ $this->expectExceptionMessageMatches('/maximum text length/i');
+ $this->validator->validate($this->dto(null, textMaxLength: -1));
+ }
+
+ public function testTextMaxLengthZeroIsAccepted(): void {
+ $this->expectNotToPerformAssertions();
+ $this->validator->validate($this->dto(null, textMaxLength: 0));
+ }
+
+ public function testNumberMinGreaterThanMaxIsRejected(): void {
+ $this->expectException(BadRequestError::class);
+ $this->expectExceptionMessageMatches('/minimum number must be less/i');
+ $this->validator->validate($this->dto(null, numberMin: 10.0, numberMax: 5.0));
+ }
+
+ public function testValidNumberRangeIsAccepted(): void {
+ $this->expectNotToPerformAssertions();
+ $this->validator->validate($this->dto(null, numberMin: 0.0, numberMax: 100.0));
+ }
+}