This is an automated email from the ASF dual-hosted git repository.
zehnder pushed a commit to branch
4258-use-data-lake-import-api-for-faster-cypress-test-data-setup-1
in repository https://gitbox.apache.org/repos/asf/streampipes.git
The following commit(s) were added to
refs/heads/4258-use-data-lake-import-api-for-faster-cypress-test-data-setup-1
by this push:
new 18e4110207 refactor(#4258): Refactor Cypress datalake test setup to
use CSV import API
18e4110207 is described below
commit 18e4110207585f2b52152cb7e13192a56ab14e1d
Author: Philipp Zehnder <[email protected]>
AuthorDate: Fri Mar 13 22:32:51 2026 +0100
refactor(#4258): Refactor Cypress datalake test setup to use CSV import API
---
ui/cypress/support/utils/PrepareTestDataUtils.ts | 64 ++---
ui/cypress/support/utils/chart/ChartUtils.ts | 85 +++----
.../support/utils/chart/ChartWidgetTableUtils.ts | 15 +-
.../support/utils/dataset/DataLakeSeedUtils.ts | 275 +++++++++++++++++++++
ui/cypress/tests/chart/configuration.smoke.spec.ts | 2 -
.../chart/filterNumericalStringProperties.spec.ts | 32 +--
ui/cypress/tests/dataset/csvImport.spec.ts | 17 ++
.../src/lib/apis/datalake-rest.service.ts | 17 ++
.../src/lib/model/datalake/csv-import.model.ts | 11 +-
.../data-download-dialog.component.html | 2 +-
.../standard-dialog/standard-dialog.component.scss | 3 +-
.../csv-import-dialog.component.html | 126 ++++++----
.../csv-import-dialog.component.scss | 49 ++--
.../csv-import-dialog.component.ts | 190 ++++----------
14 files changed, 555 insertions(+), 333 deletions(-)
diff --git a/ui/cypress/support/utils/PrepareTestDataUtils.ts
b/ui/cypress/support/utils/PrepareTestDataUtils.ts
index be300a7932..da2bceaea2 100644
--- a/ui/cypress/support/utils/PrepareTestDataUtils.ts
+++ b/ui/cypress/support/utils/PrepareTestDataUtils.ts
@@ -16,10 +16,7 @@
*
*/
-import { FileManagementUtils } from './FileManagementUtils';
-import { ConnectUtils } from './connect/ConnectUtils';
-import { AdapterBuilder } from '../builder/AdapterBuilder';
-import { ConnectBtns } from './connect/ConnectBtns';
+import { DataLakeSeedUtils } from './dataset/DataLakeSeedUtils';
export class PrepareTestDataUtils {
public static dataName = 'prepared_data';
@@ -29,52 +26,25 @@ export class PrepareTestDataUtils {
format: 'csv' | 'json_array' = 'csv',
storeInDataLake: boolean = true,
) {
- // Create adapter with dataset
- FileManagementUtils.addFile(dataSet);
-
- const adapter = this.getDataLakeTestAdapter(
- PrepareTestDataUtils.dataName,
- format,
- storeInDataLake,
- );
-
- ConnectUtils.addAdapter(adapter);
-
- ConnectUtils.startAdapter(adapter, true);
- }
-
- private static getDataLakeTestAdapter(
- name: string,
- format: 'csv' | 'json_array',
- storeInDataLake: boolean = true,
- ) {
- const adapterBuilder = AdapterBuilder.create('File_Stream')
- .setName(name)
- .setTimestampProperty('timestamp')
- .addProtocolInput(
- 'radio',
- 'speed',
- 'fastest_\\(ignore_original_time\\)',
- )
- .addProtocolInput('radio', 'replayonce', 'yes');
+ if (!storeInDataLake) {
+ throw new Error(
+ 'Direct datalake test seeding only supports persisted
datasets.',
+ );
+ }
if (format === 'csv') {
- adapterBuilder
- .setFormat('csv')
- .addFormatInput('input', ConnectBtns.csvDelimiter(), ';')
- .addFormatInput('checkbox', ConnectBtns.csvHeader(), 'check');
+ return DataLakeSeedUtils.importCsvFixture({
+ fixture: dataSet,
+ measurementName: PrepareTestDataUtils.dataName,
+ delimiter: ';',
+ timestampColumn: 'timestamp',
+ });
} else {
- adapterBuilder
- .setFormat('json')
- .addFormatInput('radio', 'json_options-array', '');
- }
-
- adapterBuilder.setStartAdapter(true);
-
- if (storeInDataLake) {
- adapterBuilder.setStoreInDataLake();
+ return DataLakeSeedUtils.importJsonArrayFixture({
+ fixture: dataSet,
+ measurementName: PrepareTestDataUtils.dataName,
+ timestampColumn: 'timestamp',
+ });
}
-
- return adapterBuilder.build();
}
}
diff --git a/ui/cypress/support/utils/chart/ChartUtils.ts
b/ui/cypress/support/utils/chart/ChartUtils.ts
index 6bf716d333..8498b045be 100644
--- a/ui/cypress/support/utils/chart/ChartUtils.ts
+++ b/ui/cypress/support/utils/chart/ChartUtils.ts
@@ -20,13 +20,11 @@ import { DataLakeFilterConfig } from
'../../model/DataLakeFilterConfig';
import { ChartWidget } from '../../model/ChartWidget';
import { DataSetUtils } from '../DataSetUtils';
import { PrepareTestDataUtils } from '../PrepareTestDataUtils';
-import { FileManagementUtils } from '../FileManagementUtils';
-import { ConnectUtils } from '../connect/ConnectUtils';
-import { ConnectBtns } from '../connect/ConnectBtns';
-import { AdapterBuilder } from '../../builder/AdapterBuilder';
import { GeneralUtils } from '../GeneralUtils';
import { ChartBtns } from './ChartBtns';
import { SharedBtns } from '../shared/SharedBtns';
+import { DataLakeSeedUtils } from '../dataset/DataLakeSeedUtils';
+import { ConnectBtns } from '../connect/ConnectBtns';
export class ChartUtils {
public static ADAPTER_NAME = 'datalake_configuration';
@@ -86,52 +84,29 @@ export class ChartUtils {
ChartUtils.loadRandomDataSetIntoDataLake();
}
- public static getDataLakeTestSetAdapter(
- name: string,
- storeInDataLake: boolean = true,
- format: 'csv' | 'json_array',
- ) {
- const adapterBuilder = AdapterBuilder.create('File_Stream')
- .setName(name)
- .setTimestampProperty('timestamp')
- .addDimensionProperty('randomtext')
- .addProtocolInput(
- 'radio',
- 'speed',
- 'fastest_\\(ignore_original_time\\)',
- )
- .setStartAdapter(true);
-
- if (format === 'csv') {
- adapterBuilder
- .setFormat('csv')
- .addFormatInput('input', ConnectBtns.csvDelimiter(), ';')
- .addFormatInput('checkbox', ConnectBtns.csvHeader(), 'check');
- } else {
- adapterBuilder.setFormat('json_array');
- }
-
- if (storeInDataLake) {
- adapterBuilder.setStoreInDataLake();
- }
- return adapterBuilder.build();
- }
-
public static loadDataIntoDataLake(
dataSet: string,
format: 'csv' | 'json_array' = 'csv',
) {
- // Create adapter with dataset
- FileManagementUtils.addFile(dataSet);
-
- const adapter = this.getDataLakeTestSetAdapter(
- ChartUtils.ADAPTER_NAME,
- true,
- format,
- );
-
- ConnectUtils.addAdapter(adapter);
- ConnectUtils.startAdapter(adapter);
+ if (format === 'csv') {
+ return DataLakeSeedUtils.importCsvFixture({
+ fixture: dataSet,
+ measurementName: ChartUtils.ADAPTER_NAME,
+ delimiter: ';',
+ timestampColumn: 'timestamp',
+ columnOverrides: {
+ randomtext: {
+ propertyScope: 'DIMENSION_PROPERTY',
+ },
+ },
+ });
+ } else {
+ return DataLakeSeedUtils.importJsonArrayFixture({
+ fixture: dataSet,
+ measurementName: ChartUtils.ADAPTER_NAME,
+ timestampColumn: 'timestamp',
+ });
+ }
}
public static addDataViewAndWidget(
@@ -304,6 +279,8 @@ export class ChartUtils {
public static createAndEditDataView() {
// Create new data view
ChartBtns.openNewDataViewBtn().click();
+ cy.location('hash').should('include', '/chart/create');
+ cy.location('hash').should('include', 'editMode=true');
}
public static removeWidget(dataViewName: string) {
@@ -431,11 +408,17 @@ export class ChartUtils {
}
public static selectDataSet(dataSet: string) {
- cy.dataCy('data-explorer-select-data-set')
- .click()
- .get('mat-option')
- .contains(dataSet)
- .click();
+ cy.get('body').then($body => {
+ if (
+ $body.find('[data-cy="data-explorer-select-data-set"]').length
+ ) {
+ cy.dataCy('data-explorer-select-data-set')
+ .click()
+ .get('mat-option')
+ .contains(dataSet)
+ .click();
+ }
+ });
}
public static assertSelectDataSet(dataSet: string) {
diff --git a/ui/cypress/support/utils/chart/ChartWidgetTableUtils.ts
b/ui/cypress/support/utils/chart/ChartWidgetTableUtils.ts
index 1f71ce8d8f..96f2540001 100644
--- a/ui/cypress/support/utils/chart/ChartWidgetTableUtils.ts
+++ b/ui/cypress/support/utils/chart/ChartWidgetTableUtils.ts
@@ -17,10 +17,15 @@
*/
export class ChartWidgetTableUtils {
- public static chartTableRowTimestamp() {
- return cy.dataCy('data-explorer-table-row-timestamp', {
- timeout: 10000,
- });
+ public static chartTableRows() {
+ return cy
+ .dataCy('data-explorer-table', {
+ timeout: 10000,
+ })
+ .filter(':visible')
+ .first()
+ .find('tbody tr')
+ .not(':has(.table-empty-row)');
}
/**
@@ -28,6 +33,6 @@ export class ChartWidgetTableUtils {
* @param amount of expected rows
*/
public static checkAmountOfRows(amount: number) {
- this.chartTableRowTimestamp().should('have.length', amount);
+ this.chartTableRows().should('have.length', amount);
}
}
diff --git a/ui/cypress/support/utils/dataset/DataLakeSeedUtils.ts
b/ui/cypress/support/utils/dataset/DataLakeSeedUtils.ts
new file mode 100644
index 0000000000..a81fbd6c6f
--- /dev/null
+++ b/ui/cypress/support/utils/dataset/DataLakeSeedUtils.ts
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import * as CSV from 'csv-string';
+
+type CsvRuntimeType = 'STRING' | 'BOOLEAN' | 'LONG' | 'FLOAT';
+type CsvImportTargetMode = 'NEW' | 'EXISTING';
+
+interface CsvImportConfiguration {
+ delimiter: string;
+ decimalSeparator: '.' | ',';
+ hasHeader: boolean;
+}
+
+interface CsvImportColumn {
+ csvColumn: string;
+ runtimeName: string;
+ runtimeType: CsvRuntimeType;
+ propertyScope?: string;
+ semanticType?: string;
+ inferredType?: CsvRuntimeType;
+ timestampCandidate?: boolean;
+}
+
+interface CsvImportTarget {
+ mode: CsvImportTargetMode;
+ measurementName: string;
+}
+
+interface CsvImportPreviewResult {
+ headers: string[];
+ previewRows: string[][];
+ columns: CsvImportColumn[];
+}
+
+interface CsvImportResult {
+ importedRowCount: number;
+ validationMessages: Array<{ field: string; message: string }>;
+}
+
+interface ColumnOverride {
+ runtimeName?: string;
+ runtimeType?: CsvRuntimeType;
+ propertyScope?: string;
+ semanticType?: string;
+}
+
+interface CsvFixtureImportOptions {
+ fixture: string;
+ measurementName: string;
+ delimiter?: string;
+ decimalSeparator?: '.' | ',';
+ timestampColumn?: string;
+ columnOverrides?: Record<string, ColumnOverride>;
+}
+
+interface JsonArrayFixtureImportOptions {
+ fixture: string;
+ measurementName: string;
+ timestampColumn?: string;
+ columnOverrides?: Record<string, ColumnOverride>;
+}
+
+interface ImportRequest {
+ csvConfig: CsvImportConfiguration;
+ headers: string[];
+ rows: string[][];
+ target: CsvImportTarget;
+ timestampColumn: string;
+ columns: CsvImportColumn[];
+}
+
+export class DataLakeSeedUtils {
+ private static readonly TIMESTAMP_SEMANTIC_TYPE =
+ 'http://schema.org/DateTime';
+
+ public static importCsvFixture(
+ options: CsvFixtureImportOptions,
+ ): Cypress.Chainable<CsvImportResult> {
+ const delimiter = options.delimiter ?? ';';
+ const decimalSeparator = options.decimalSeparator ?? '.';
+
+ return cy.fixture(options.fixture, 'utf8').then((content: string) => {
+ const parseCsv = CSV.parse as any;
+ const parsedCsv = parseCsv(content, delimiter);
+ const headers = parsedCsv[0];
+ const rows = parsedCsv.slice(1);
+ const timestampColumn = options.timestampColumn ?? headers[0];
+
+ return this.previewAndImport({
+ headers,
+ rows,
+ csvConfig: {
+ delimiter,
+ decimalSeparator,
+ hasHeader: true,
+ },
+ measurementName: options.measurementName,
+ timestampColumn,
+ columnOverrides: options.columnOverrides,
+ });
+ });
+ }
+
+ public static importJsonArrayFixture(
+ options: JsonArrayFixtureImportOptions,
+ ): Cypress.Chainable<CsvImportResult> {
+ return cy.fixture(options.fixture).then((records: Array<any>) => {
+ const headers = this.extractHeaders(records);
+ const rows = records.map(record =>
+ headers.map(header =>
+ this.serializeCell(record ? record[header] : undefined),
+ ),
+ );
+ const timestampColumn = options.timestampColumn ?? headers[0];
+
+ return this.previewAndImport({
+ headers,
+ rows,
+ csvConfig: {
+ delimiter: ';',
+ decimalSeparator: '.',
+ hasHeader: true,
+ },
+ measurementName: options.measurementName,
+ timestampColumn,
+ columnOverrides: options.columnOverrides,
+ });
+ });
+ }
+
+ private static previewAndImport(options: {
+ headers: string[];
+ rows: string[][];
+ csvConfig: CsvImportConfiguration;
+ measurementName: string;
+ timestampColumn: string;
+ columnOverrides?: Record<string, ColumnOverride>;
+ }): Cypress.Chainable<CsvImportResult> {
+ const token = window.localStorage.getItem('auth-token');
+ const target = {
+ mode: 'NEW' as CsvImportTargetMode,
+ measurementName: options.measurementName,
+ };
+
+ return cy
+ .request<CsvImportPreviewResult>({
+ method: 'POST',
+ url: '/streampipes-backend/api/v4/datalake/import/preview',
+ body: {
+ csvConfig: options.csvConfig,
+ headers: options.headers,
+ rows: options.rows,
+ target,
+ },
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .then(previewResponse => {
+ const columns = this.buildColumns(
+ previewResponse.body.columns,
+ options.timestampColumn,
+ options.columnOverrides ?? {},
+ );
+
+ const request: ImportRequest = {
+ csvConfig: options.csvConfig,
+ headers: options.headers,
+ rows: options.rows,
+ target,
+ timestampColumn: options.timestampColumn,
+ columns,
+ };
+
+ return cy
+ .request<CsvImportResult>({
+ method: 'POST',
+ url: '/streampipes-backend/api/v4/datalake/import',
+ body: request,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ .then(importResponse => {
+ expect(
+ importResponse.body.validationMessages,
+ 'import validation messages',
+ ).to.have.length(0);
+ expect(
+ importResponse.body.importedRowCount,
+ 'imported row count',
+ ).to.equal(options.rows.length);
+ return importResponse.body;
+ });
+ });
+ }
+
+ private static buildColumns(
+ previewColumns: CsvImportColumn[],
+ timestampColumn: string,
+ columnOverrides: Record<string, ColumnOverride>,
+ ) {
+ return previewColumns.map(previewColumn => {
+ const override = columnOverrides[previewColumn.csvColumn] ?? {};
+ if (previewColumn.csvColumn === timestampColumn) {
+ return {
+ ...previewColumn,
+ runtimeName:
+ override.runtimeName ?? previewColumn.runtimeName,
+ runtimeType: override.runtimeType ?? 'LONG',
+ propertyScope: 'HEADER_PROPERTY',
+ semanticType:
+ override.semanticType ??
+ DataLakeSeedUtils.TIMESTAMP_SEMANTIC_TYPE,
+ };
+ }
+
+ return {
+ ...previewColumn,
+ runtimeName: override.runtimeName ?? previewColumn.runtimeName,
+ runtimeType:
+ override.runtimeType ??
+ this.defaultRuntimeType(previewColumn.inferredType),
+ propertyScope: override.propertyScope ??
'MEASUREMENT_PROPERTY',
+ semanticType: override.semanticType ?? undefined,
+ };
+ });
+ }
+
+ private static defaultRuntimeType(
+ inferredType: CsvRuntimeType = 'STRING',
+ ): CsvRuntimeType {
+ if (inferredType === 'LONG' || inferredType === 'FLOAT') {
+ return 'FLOAT';
+ }
+
+ return inferredType;
+ }
+
+ private static extractHeaders(records: Array<any>) {
+ const headers: string[] = [];
+ records.forEach(record => {
+ Object.keys(record || {}).forEach(key => {
+ if (headers.indexOf(key) === -1) {
+ headers.push(key);
+ }
+ });
+ });
+ return headers;
+ }
+
+ private static serializeCell(value: any): string {
+ if (value === undefined || value === null) {
+ return '';
+ }
+
+ return String(value);
+ }
+}
diff --git a/ui/cypress/tests/chart/configuration.smoke.spec.ts
b/ui/cypress/tests/chart/configuration.smoke.spec.ts
index 9080a80515..a09a831321 100644
--- a/ui/cypress/tests/chart/configuration.smoke.spec.ts
+++ b/ui/cypress/tests/chart/configuration.smoke.spec.ts
@@ -16,7 +16,6 @@
*
*/
-import { PipelineUtils } from '../../support/utils/pipeline/PipelineUtils';
import { ChartUtils } from '../../support/utils/chart/ChartUtils';
import { ChartBtns } from '../../support/utils/chart/ChartBtns';
import { GeneralUtils } from '../../support/utils/GeneralUtils';
@@ -56,7 +55,6 @@ describe('Delete data in datalake', () => {
before('Setup Test', () => {
cy.initStreamPipesTest();
ChartUtils.loadRandomDataSetIntoDataLake();
- PipelineUtils.deletePipeline('Persist prepared_data');
});
it('Perform Test', () => {
diff --git a/ui/cypress/tests/chart/filterNumericalStringProperties.spec.ts
b/ui/cypress/tests/chart/filterNumericalStringProperties.spec.ts
index 2bd393488b..f09f65a856 100644
--- a/ui/cypress/tests/chart/filterNumericalStringProperties.spec.ts
+++ b/ui/cypress/tests/chart/filterNumericalStringProperties.spec.ts
@@ -19,30 +19,24 @@
import { ChartUtils } from '../../support/utils/chart/ChartUtils';
import { ChartWidgetTableUtils } from
'../../support/utils/chart/ChartWidgetTableUtils';
import { DataLakeFilterConfig } from
'../../support/model/DataLakeFilterConfig';
-import { AdapterBuilder } from '../../support/builder/AdapterBuilder';
-import { ConnectBtns } from '../../support/utils/connect/ConnectBtns';
-import { ConnectUtils } from '../../support/utils/connect/ConnectUtils';
-import { FileManagementUtils } from '../../support/utils/FileManagementUtils';
import { ChartWidget } from '../../support/model/ChartWidget';
+import { DataLakeSeedUtils } from
'../../support/utils/dataset/DataLakeSeedUtils';
describe('Validate that filter works for numerical dimension property', () => {
beforeEach('Setup Test', () => {
cy.initStreamPipesTest();
-
- FileManagementUtils.addFile(
- 'datalake/filterNumericalStringProperties.csv',
- );
- const adapterInput = AdapterBuilder.create('File_Stream')
- .setName('Test Adapter')
- .setTimestampProperty('timestamp')
- .addDataTypeChange('dimensionKey', 'Integer')
- .addDimensionProperty('dimensionKey')
- .setStoreInDataLake()
- .setFormat('csv')
- .addFormatInput('input', ConnectBtns.csvDelimiter(), ';')
- .addFormatInput('checkbox', ConnectBtns.csvHeader(), 'check')
- .build();
- ConnectUtils.testAdapter(adapterInput);
+ DataLakeSeedUtils.importCsvFixture({
+ fixture: 'datalake/filterNumericalStringProperties.csv',
+ measurementName: 'Test Adapter',
+ delimiter: ';',
+ timestampColumn: 'timestamp',
+ columnOverrides: {
+ dimensionKey: {
+ runtimeType: 'LONG',
+ propertyScope: 'DIMENSION_PROPERTY',
+ },
+ },
+ });
});
it('Perform Test', () => {
diff --git a/ui/cypress/tests/dataset/csvImport.spec.ts
b/ui/cypress/tests/dataset/csvImport.spec.ts
index 63f7a9f9c9..f7ff816887 100644
--- a/ui/cypress/tests/dataset/csvImport.spec.ts
+++ b/ui/cypress/tests/dataset/csvImport.spec.ts
@@ -22,6 +22,7 @@ describe('CSV import happy path', () => {
const datasetName = 'csv_machine_data_import';
const stringTimestampDatasetName = 'csv_machine_data_import_string_ts';
const existingDatasetName = 'csv_machine_data_existing_import';
+ const missingValuesDatasetName = 'csv_machine_data_missing_values';
beforeEach('Setup Test', () => {
cy.initStreamPipesTest();
@@ -57,6 +58,22 @@ describe('CSV import happy path', () => {
);
});
+ it('Uploads a CSV file with missing values and still imports all rows', ()
=> {
+ DatasetUtils.openCsvImportDialog();
+ DatasetUtils.uploadCsvImportFile(
+ 'datalake/machine-data-simulator-import-missing-values.csv',
+ );
+ DatasetUtils.createNewDatasetFromCsv(missingValuesDatasetName);
+ DatasetUtils.continueCsvImportToPreview();
+ DatasetUtils.selectCsvImportDelimiterComma();
+ DatasetUtils.selectCsvImportTimestampColumn(0);
+ DatasetUtils.uploadCsvImport();
+ DatasetUtils.expectDatasetTotalEventCount(
+ missingValuesDatasetName,
+ '7',
+ );
+ });
+
it('Appends matching data to an existing dataset and warns on mismatched
timestamp schema', () => {
DatasetUtils.openCsvImportDialog();
DatasetUtils.uploadCsvImportFile(
diff --git
a/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
b/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
index cd92d0362c..4413701820 100644
---
a/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
+++
b/ui/projects/streampipes/platform-services/src/lib/apis/datalake-rest.service.ts
@@ -247,7 +247,24 @@ export class DatalakeRestService {
previewImport(
request: CsvImportPreviewRequest,
+ file?: File,
): Observable<CsvImportPreviewResult> {
+ if (file) {
+ const formData = new FormData();
+ formData.append('file', file, file.name);
+ formData.append(
+ 'request',
+ new Blob([JSON.stringify(request)], {
+ type: 'application/json',
+ }),
+ );
+
+ return this.http.post<CsvImportPreviewResult>(
+ `${this.dataLakeImportUrl}/preview`,
+ formData,
+ );
+ }
+
return this.http.post<CsvImportPreviewResult>(
`${this.dataLakeImportUrl}/preview`,
request,
diff --git
a/ui/projects/streampipes/platform-services/src/lib/model/datalake/csv-import.model.ts
b/ui/projects/streampipes/platform-services/src/lib/model/datalake/csv-import.model.ts
index 00f058fca2..98edf75c48 100644
---
a/ui/projects/streampipes/platform-services/src/lib/model/datalake/csv-import.model.ts
+++
b/ui/projects/streampipes/platform-services/src/lib/model/datalake/csv-import.model.ts
@@ -51,14 +51,16 @@ export interface CsvImportValidationMessage {
}
export interface CsvImportPreviewRequest {
+ uploadId?: string;
fileName?: string;
csvConfig: CsvImportConfiguration;
- headers: string[];
- rows: string[][];
+ headers?: string[];
+ rows?: string[][];
target?: CsvImportTarget;
}
export interface CsvImportPreviewResult {
+ uploadId?: string;
headers: string[];
previewRows: string[][];
columns: CsvImportColumn[];
@@ -94,9 +96,10 @@ export interface CsvImportSchemaValidationResult {
}
export interface CsvImportRequest {
+ uploadId?: string;
csvConfig: CsvImportConfiguration;
- headers: string[];
- rows: string[][];
+ headers?: string[];
+ rows?: string[][];
target: CsvImportTarget;
timestampColumn: string;
columns: CsvImportColumn[];
diff --git
a/ui/projects/streampipes/shared-ui/src/lib/dialog/data-download-dialog/data-download-dialog.component.html
b/ui/projects/streampipes/shared-ui/src/lib/dialog/data-download-dialog/data-download-dialog.component.html
index 723a79fbc3..ebd0780566 100644
---
a/ui/projects/streampipes/shared-ui/src/lib/dialog/data-download-dialog/data-download-dialog.component.html
+++
b/ui/projects/streampipes/shared-ui/src/lib/dialog/data-download-dialog/data-download-dialog.component.html
@@ -53,7 +53,7 @@
</div>
<mat-divider></mat-divider>
- <div class="sp-dialog-actions actions-align-right">
+ <div class="sp-dialog-actions actions-align-left">
<button
mat-button
mat-flat-button
diff --git
a/ui/projects/streampipes/shared-ui/src/lib/dialog/standard-dialog/standard-dialog.component.scss
b/ui/projects/streampipes/shared-ui/src/lib/dialog/standard-dialog/standard-dialog.component.scss
index 4af62bebaf..9ce08157de 100644
---
a/ui/projects/streampipes/shared-ui/src/lib/dialog/standard-dialog/standard-dialog.component.scss
+++
b/ui/projects/streampipes/shared-ui/src/lib/dialog/standard-dialog/standard-dialog.component.scss
@@ -47,7 +47,8 @@ standard-dialog-container {
.dialog-panel-content {
height: 100%;
- overflow-y: auto;
+ overflow: auto;
+ min-width: 0;
}
.dialog-title {
diff --git
a/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.html
b/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.html
index 92b33e8de6..3f2bd24950 100644
---
a/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.html
+++
b/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.html
@@ -227,12 +227,6 @@
[level]="3"
[title]="'Live preview' | translate"
>
- @if (currentTarget) {
- <div section-actions class="target-pill">
- {{ currentTargetLabel }}
- </div>
- }
-
@if (hasSchemaMismatch) {
<sp-alert-banner
type="error"
@@ -261,6 +255,18 @@
}
}
+ @if (showTimestampSelectionWarning) {
+ <sp-alert-banner
+ type="warning"
+ data-cy="csv-import-timestamp-warning"
+ [title]="'Warning' | translate"
+ [description]="
+ 'Please select exactly one timestamp column
before uploading the CSV.'
+ | translate
+ "
+ ></sp-alert-banner>
+ }
+
@if (hasPreview) {
<div
class="preview-table-wrapper"
@@ -300,50 +306,46 @@
data-cy="csv-import-timestamp-format"
/>
</mat-form-field>
- }
- <mat-form-field
- appearance="outline"
- class="w-100"
- >
- <mat-select
- [value]="
- model.column
-
.runtimeType ||
- model.column
-
.inferredType ||
- 'STRING'
- "
- [disabled]="
-
isTimestampColumn(
- model
- )
- "
- (selectionChange)="
- setColumnType(
- model,
-
$event.value
- )
- "
-
data-cy="csv-import-column-type"
+ } @else {
+ <mat-form-field
+
appearance="outline"
+ class="w-100"
>
- <mat-option
- value="STRING"
-
>STRING</mat-option
- >
- <mat-option
- value="BOOLEAN"
-
>BOOLEAN</mat-option
- >
- <mat-option
- value="LONG"
-
>LONG</mat-option
- >
- <mat-option
- value="FLOAT"
-
>FLOAT</mat-option
+ <mat-select
+ [value]="
+
model.column
+
.runtimeType ||
+
model.column
+
.inferredType ||
+ 'STRING'
+ "
+
(selectionChange)="
+
setColumnType(
+ model,
+
$event.value
+ )
+ "
+
data-cy="csv-import-column-type"
>
- </mat-select>
- </mat-form-field>
+ <mat-option
+
value="STRING"
+
>STRING</mat-option
+ >
+ <mat-option
+
value="BOOLEAN"
+
>BOOLEAN</mat-option
+ >
+ <mat-option
+
value="LONG"
+
>LONG</mat-option
+ >
+ <mat-option
+
value="FLOAT"
+
>FLOAT</mat-option
+ >
+ </mat-select>
+ </mat-form-field>
+ }
<mat-form-field
appearance="outline"
class="w-100"
@@ -458,6 +460,32 @@
{{ importResult?.importedRowCount }}
{{ 'rows imported' | translate }}
</div>
+ } @else if (hasUploadError) {
+ <sp-alert-banner
+ type="error"
+ data-cy="csv-import-upload-error"
+ [title]="'Import failed' | translate"
+ [description]="uploadErrorMessages[0].message"
+ ></sp-alert-banner>
+
+ @if (uploadErrorMessages.length > 1) {
+ <ul
+ class="schema-mismatch-list"
+ data-cy="csv-import-upload-error-list"
+ >
+ @for (
+ message of
uploadErrorMessages.slice(1);
+ track message.message
+ ) {
+ <li
+ class="schema-mismatch-item"
+
data-cy="csv-import-upload-error-item"
+ >
+ {{ message.message }}
+ </li>
+ }
+ </ul>
+ }
}
</div>
</sp-split-section>
@@ -471,7 +499,7 @@
<mat-divider></mat-divider>
- <div class="sp-dialog-actions actions-align-right">
+ <div class="sp-dialog-actions actions-align-left">
<button
mat-button
mat-flat-button
diff --git
a/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.scss
b/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.scss
index c598628067..a40c8a13d3 100644
---
a/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.scss
+++
b/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.scss
@@ -10,6 +10,27 @@
overflow: auto;
}
+:host {
+ display: block;
+ width: 100%;
+ min-width: 0;
+}
+
+:host ::ng-deep sp-split-section {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+}
+
+:host ::ng-deep sp-split-section .section-outer,
+:host ::ng-deep sp-split-section .section-body,
+:host ::ng-deep sp-split-section .section-body-compact {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+}
+
.section-disabled {
opacity: 0.65;
}
@@ -86,15 +107,23 @@
}
.preview-table-wrapper {
- overflow: auto;
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: auto;
border: 1px solid var(--color-border, #d8dee4);
border-radius: 14px;
+ padding-bottom: 4px;
+ -webkit-overflow-scrolling: touch;
+ box-sizing: border-box;
}
.preview-table {
- width: 100%;
+ width: max-content;
+ min-width: 100%;
border-collapse: collapse;
- min-width: 900px;
+ table-layout: auto;
}
.preview-table th,
@@ -118,7 +147,8 @@
display: flex;
flex-direction: column;
gap: 10px;
- min-width: 220px;
+ min-width: 240px;
+ width: 240px;
}
.header-title {
@@ -136,17 +166,6 @@
font-size: 12px;
}
-.target-pill {
- display: inline-flex;
- align-items: center;
- padding: 4px 10px;
- border-radius: 999px;
- background: #eef4ff;
- color: #1849a9;
- font-size: 12px;
- font-weight: 600;
-}
-
.message-box {
border-radius: 12px;
background: #fff4e5;
diff --git
a/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.ts
b/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.ts
index aa98238d6c..3878443b76 100644
--- a/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.ts
+++ b/ui/src/app/dataset/dialog/csv-import-dialog/csv-import-dialog.component.ts
@@ -102,11 +102,10 @@ export class CsvImportDialogComponent {
private previewReloadTimeout?: ReturnType<typeof setTimeout>;
private schemaValidationTimeout?: ReturnType<typeof setTimeout>;
+ selectedFile?: File;
+ uploadId?: string;
fileName = '';
- rawFileContent = '';
timestampFormat = '';
- parsedHeaders: string[] = [];
- parsedRows: string[][] = [];
previewResult?: CsvImportPreviewResult;
schemaValidationResult?: CsvImportSchemaValidationResult;
importResult?: CsvImportResult;
@@ -116,8 +115,8 @@ export class CsvImportDialogComponent {
localMessages: CsvImportValidationMessage[] = [];
parseForm = this.fb.group({
- delimiter: [';', Validators.required],
- decimalSeparator: [',' as ',' | '.', Validators.required],
+ delimiter: [',' as ',' | ';' | '|' | '\\t', Validators.required],
+ decimalSeparator: ['.' as ',' | '.', Validators.required],
hasHeader: [true, Validators.required],
});
@@ -180,8 +179,16 @@ export class CsvImportDialogComponent {
return !!this.importResult?.measurementName;
}
- get previewColumns(): string[] {
- return this.previewResult?.headers ?? [];
+ get uploadErrorMessages(): CsvImportValidationMessage[] {
+ if (this.importLoading || this.hasImportResult) {
+ return [];
+ }
+
+ return this.importResult?.validationMessages ?? [];
+ }
+
+ get hasUploadError(): boolean {
+ return this.uploadErrorMessages.length > 0;
}
get previewRows(): string[][] {
@@ -198,16 +205,8 @@ export class CsvImportDialogComponent {
)?.column.runtimeName;
}
- get canConfigureColumns(): boolean {
- return this.hasPreview;
- }
-
- get canConfigureParse(): boolean {
- return this.isTargetValid;
- }
-
get canProceedToConfiguration(): boolean {
- return this.isTargetValid && !!this.rawFileContent;
+ return this.isTargetValid && !!this.selectedFile;
}
get canImport(): boolean {
@@ -220,6 +219,10 @@ export class CsvImportDialogComponent {
);
}
+ get showTimestampSelectionWarning(): boolean {
+ return this.hasPreview && !this.selectedTimestampColumn;
+ }
+
get selectedTimestampColumnModel(): CsvImportColumnModel | undefined {
return this.columnModels.find(model => this.isTimestampColumn(model));
}
@@ -263,13 +266,6 @@ export class CsvImportDialogComponent {
);
}
- get currentTargetLabel(): string {
- if (!this.currentTarget) {
- return '-';
- }
- return this.currentTarget.measurementName;
- }
-
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
@@ -278,20 +274,17 @@ export class CsvImportDialogComponent {
}
this.fileName = file.name;
+ this.selectedFile = file;
+ this.uploadId = undefined;
this.timestampFormat = '';
- const reader = new FileReader();
- reader.onload = () => {
- this.rawFileContent = `${reader.result ?? ''}`;
- this.invalidatePreview();
- };
- reader.readAsText(file);
+ this.invalidatePreview();
}
nextStep(): void {
if (this.csvImportStepper.selectedIndex === 0) {
if (!this.canProceedToConfiguration) {
this.localMessages = this.validateLocalTarget();
- if (!this.rawFileContent) {
+ if (!this.selectedFile) {
this.localMessages.push({
field: 'file',
message: 'Please select a CSV file first.',
@@ -339,34 +332,24 @@ export class CsvImportDialogComponent {
return;
}
- if (!this.rawFileContent) {
+ if (!this.selectedFile && !this.uploadId) {
this.localMessages = [
{ field: 'file', message: 'Please select a CSV file first.' },
];
return;
}
- try {
- const { headers, rows } = this.parseCsv();
- this.parsedHeaders = headers;
- this.parsedRows = rows;
- } catch (error) {
- this.localMessages = [
- {
- field: 'file',
- message:
- 'The CSV file could not be parsed with the current
settings.',
- },
- ];
- return;
- }
-
this.previewLoading = true;
+ const useMultipartUpload = !!this.selectedFile && !this.uploadId;
this.datalakeRestService
- .previewImport(this.buildPreviewRequest(this.currentTarget))
+ .previewImport(
+ this.buildPreviewRequest(this.currentTarget),
+ useMultipartUpload ? this.selectedFile : undefined,
+ )
.subscribe({
next: preview => {
this.previewResult = preview;
+ this.uploadId = preview.uploadId ?? this.uploadId;
this.columnModels = preview.columns.map(column =>
this.toColumnModel(column),
);
@@ -469,9 +452,23 @@ export class CsvImportDialogComponent {
},
error: error => {
this.importLoading = false;
- this.importResult = error?.error as CsvImportResult;
- if (!this.hasImportResult) {
- this.csvImportStepper.previous();
+ this.importResult = (error?.error as CsvImportResult) ?? {
+ measurementId: '',
+ measurementName: '',
+ createdNewMeasurement: false,
+ importedRowCount: 0,
+ validationMessages: [],
+ };
+
+ if (!this.importResult.validationMessages?.length) {
+ this.importResult.validationMessages = [
+ {
+ field: 'upload',
+ message:
+ error?.error?.message ??
+ 'The CSV import failed. Please review the
import configuration and try again.',
+ },
+ ];
}
},
});
@@ -481,106 +478,21 @@ export class CsvImportDialogComponent {
this.dialogRef.close(refresh);
}
- private parseCsv(): { headers: string[]; rows: string[][] } {
- const delimiter = this.normalizeDelimiter(
- this.parseForm.get('delimiter')?.value ?? ';',
- );
- const parsed = this.parseCsvContent(this.rawFileContent, delimiter);
- const rows = parsed.filter(row =>
- row.some(cell => `${cell}`.trim() !== ''),
- );
- if (rows.length === 0) {
- throw new Error('CSV contains no rows');
- }
-
- const hasHeader = this.parseForm.get('hasHeader')?.value ?? true;
- let headers: string[];
- let contentRows: string[][];
-
- if (hasHeader) {
- headers = rows[0].map((header, index) =>
- this.normalizeHeader(
- index === 0 ? this.stripBom(header) : header,
- index,
- ),
- );
- contentRows = rows.slice(1);
- } else {
- headers = rows[0].map((_, index) => `column_${index + 1}`);
- contentRows = rows;
- }
-
- return { headers, rows: contentRows };
- }
-
- private normalizeDelimiter(value: string): string {
- return value === '\\t' ? '\t' : value;
- }
-
- private stripBom(value: string): string {
- return value.replace(/^\uFEFF/, '');
- }
-
- private normalizeHeader(value: string, index: number): string {
- const trimmed = value?.trim();
- return trimmed ? trimmed : `column_${index + 1}`;
- }
-
- private parseCsvContent(content: string, delimiter: string): string[][] {
- const rows: string[][] = [];
- let currentRow: string[] = [];
- let currentValue = '';
- let inQuotes = false;
-
- for (let i = 0; i < content.length; i++) {
- const char = content[i];
- const nextChar = content[i + 1];
-
- if (char === '"') {
- if (inQuotes && nextChar === '"') {
- currentValue += '"';
- i += 1;
- } else {
- inQuotes = !inQuotes;
- }
- } else if (!inQuotes && char === delimiter) {
- currentRow.push(currentValue);
- currentValue = '';
- } else if (!inQuotes && (char === '\n' || char === '\r')) {
- if (char === '\r' && nextChar === '\n') {
- i += 1;
- }
- currentRow.push(currentValue);
- rows.push(currentRow);
- currentRow = [];
- currentValue = '';
- } else {
- currentValue += char;
- }
- }
-
- currentRow.push(currentValue);
- rows.push(currentRow);
- return rows;
- }
-
private buildPreviewRequest(
target?: CsvImportTarget,
): CsvImportPreviewRequest {
return {
+ uploadId: this.uploadId,
fileName: this.fileName,
csvConfig: this.currentCsvConfig,
- headers: this.parsedHeaders,
- rows: this.parsedRows,
target,
};
}
private buildImportRequest(): CsvImportRequest {
return {
+ uploadId: this.uploadId,
csvConfig: this.currentCsvConfig,
- headers: this.parsedHeaders,
- rows: this.parsedRows,
target: this.currentTarget!,
timestampColumn: this.selectedTimestampColumn!,
columns: this.columnModels.map(model => model.column),
@@ -629,10 +541,10 @@ export class CsvImportDialogComponent {
private get currentCsvConfig(): CsvImportConfiguration {
return {
- delimiter: this.parseForm.get('delimiter')?.value ?? ';',
+ delimiter: this.parseForm.get('delimiter')?.value ?? ',',
decimalSeparator:
(this.parseForm.get('decimalSeparator')?.value as ',' | '.') ??
- ',',
+ '.',
hasHeader: this.parseForm.get('hasHeader')?.value ?? true,
timestampFormat: this.timestampFormat.trim() || undefined,
};