From 7a15e9584da6dd0398f26bc4a6bf605c68eeeb96 Mon Sep 17 00:00:00 2001 From: Matthew Ball Date: Sat, 2 May 2026 01:32:35 -0700 Subject: [PATCH 1/6] admin-adjustable max columns with a clear overflow message --- common/config/src/main/resources/default.conf | 10 ++ .../org/apache/texera/dao/SiteSettings.scala | 54 +++++++++ .../source/scan/csv/CSVScanSourceOpDesc.scala | 7 +- .../source/scan/csv/CSVScanSourceOpExec.scala | 46 ++++++- .../scan/csv/CSVScanSourceOpExecSpec.scala | 103 ++++++++++++++++ .../service/resource/ConfigResource.scala | 6 +- .../service/resource/DatasetResource.scala | 16 +-- .../settings/admin-settings.component.html | 49 ++++++++ .../settings/admin-settings.component.spec.ts | 114 +++++++++++++++++- .../settings/admin-settings.component.ts | 53 +++++++- .../result-table-frame.component.spec.ts | 52 +++++++- .../result-table-frame.component.ts | 17 ++- 12 files changed, 503 insertions(+), 24 deletions(-) create mode 100644 common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala create mode 100644 common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala diff --git a/common/config/src/main/resources/default.conf b/common/config/src/main/resources/default.conf index 0ce2a2e38b6..36ecc80cbad 100644 --- a/common/config/src/main/resources/default.conf +++ b/common/config/src/main/resources/default.conf @@ -91,3 +91,13 @@ dataset { multipart_upload_chunk_size_mib = 50 multipart_upload_chunk_size_mib = ${?DATASET_MULTIPART_UPLOAD_CHUNK_SIZE_MIB} } + +csv { + csv_parser_max_columns = 512 + csv_parser_max_columns = ${?CSV_PARSER_MAX_COLUMNS} +} + +result-table { + result_table_columns_per_batch = 15 + result_table_columns_per_batch = ${?RESULT_TABLE_COLUMNS_PER_BATCH} +} diff --git a/common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala b/common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala new file mode 100644 index 00000000000..f93772c406d --- /dev/null +++ b/common/dao/src/main/scala/org/apache/texera/dao/SiteSettings.scala @@ -0,0 +1,54 @@ +/* + * 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. + */ + +package org.apache.texera.dao + +import org.jooq.impl.DSL + +import scala.util.Try + +/** + * Read-side accessor for the `site_settings` key/value table that admin pages + * write through. Centralises the "look up by key, parse, fall back on any + * failure" pattern that previously lived inline in ConfigResource, + * CSVScanSourceOpExec, and DatasetResource. + * + * Failures swallowed by the outer Try include: SqlServer not initialised + * (e.g. on workers in distributed mode), no row for the key, and value that + * can't be parsed. In all of these cases the caller's default takes over. + */ +object SiteSettings { + + def getInt(key: String, default: => Int): Int = + readAndParse(key, default)(_.toInt) + + def getLong(key: String, default: => Long): Long = + readAndParse(key, default)(_.toLong) + + private def readAndParse[T](key: String, default: => T)(parse: String => T): T = + Try { + val raw = SqlServer + .getInstance() + .createDSLContext() + .select(DSL.field("value", classOf[String])) + .from(DSL.table(DSL.name("texera_db", "site_settings"))) + .where(DSL.field("key", classOf[String]).eq(key)) + .fetchOneInto(classOf[String]) + Option(raw).map(s => parse(s.trim)).getOrElse(default) + }.getOrElse(default) +} diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala index a44e2765d5e..57b173583ec 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDesc.scala @@ -29,6 +29,7 @@ import org.apache.texera.amber.core.tuple.{AttributeType, Schema} import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, WorkflowIdentity} import org.apache.texera.amber.core.workflow.{PhysicalOp, SchemaPropagationFunc} import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc +import org.apache.texera.amber.operator.source.scan.csv.CSVScanSourceOpExec import org.apache.texera.amber.util.JSONUtils.objectMapper import java.io.{IOException, InputStreamReader} @@ -89,6 +90,8 @@ class CSVScanSourceOpDesc extends ScanSourceOpDesc { csvFormat.setLineSeparator("\n") val csvSetting = new CsvParserSettings() csvSetting.setMaxCharsPerColumn(-1) + val maxColumns = CSVScanSourceOpExec.getMaxColumns + csvSetting.setMaxColumns(maxColumns) csvSetting.setFormat(csvFormat) csvSetting.setHeaderExtractionEnabled(hasHeader) csvSetting.setNullValue("") @@ -97,8 +100,8 @@ class CSVScanSourceOpDesc extends ScanSourceOpDesc { var data: Array[Array[String]] = Array() val readLimit = limit.getOrElse(INFER_READ_LIMIT).min(INFER_READ_LIMIT) - for (i <- 0 until readLimit) { - val row = parser.parseNext() + for (_ <- 0 until readLimit) { + val row = CSVScanSourceOpExec.parseNextRow(parser, maxColumns) if (row != null) { data = data :+ row } diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala index c3fbbe9bb55..13227573fd0 100644 --- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala +++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExec.scala @@ -19,15 +19,18 @@ package org.apache.texera.amber.operator.source.scan.csv +import com.univocity.parsers.common.TextParsingException import com.univocity.parsers.csv.{CsvFormat, CsvParser, CsvParserSettings} import org.apache.texera.amber.core.executor.SourceOperatorExecutor import org.apache.texera.amber.core.storage.DocumentFactory import org.apache.texera.amber.core.tuple.{AttributeTypeUtils, Schema, TupleLike} import org.apache.texera.amber.util.JSONUtils.objectMapper +import org.apache.texera.dao.SiteSettings import java.io.InputStreamReader import java.net.URI import scala.collection.immutable.ArraySeq +import scala.util.Try class CSVScanSourceOpExec private[csv] (descString: String) extends SourceOperatorExecutor { val desc: CSVScanSourceOpDesc = objectMapper.readValue(descString, classOf[CSVScanSourceOpDesc]) @@ -35,6 +38,7 @@ class CSVScanSourceOpExec private[csv] (descString: String) extends SourceOperat var parser: CsvParser = _ var nextRow: Array[String] = _ var numRowGenerated = 0 + private var maxColumns: Int = CSVScanSourceOpExec.DEFAULT_MAX_COLUMNS private val schema: Schema = desc.sourceSchema() override def produceTuple(): Iterator[TupleLike] = { @@ -44,7 +48,7 @@ class CSVScanSourceOpExec private[csv] (descString: String) extends SourceOperat if (nextRow != null) { return true } - nextRow = parser.parseNext() + nextRow = CSVScanSourceOpExec.parseNextRow(parser, maxColumns) nextRow != null } @@ -90,6 +94,8 @@ class CSVScanSourceOpExec private[csv] (descString: String) extends SourceOperat ) // disable skipping lines starting with # (default comment character) val csvSetting = new CsvParserSettings() csvSetting.setMaxCharsPerColumn(-1) + maxColumns = CSVScanSourceOpExec.getMaxColumns + csvSetting.setMaxColumns(maxColumns) csvSetting.setFormat(csvFormat) csvSetting.setHeaderExtractionEnabled(desc.hasHeader) @@ -106,3 +112,41 @@ class CSVScanSourceOpExec private[csv] (descString: String) extends SourceOperat } } } + +object CSVScanSourceOpExec { + val DEFAULT_MAX_COLUMNS = 512 + + def getMaxColumns: Int = + SiteSettings.getInt("csv_parser_max_columns", DEFAULT_MAX_COLUMNS) + + /** + * Wraps `parser.parseNext()` so a column-count overflow is reported to the user + * as a clear instruction rather than a deep Univocity stack trace. Other parser + * failures are rethrown unchanged. + * + * The thrown RuntimeException's message bubbles up through DataProcessor.handleExecutorException + * and becomes the title of the console message that drives the top-of-page toast. + */ + def parseNextRow(parser: CsvParser, maxColumns: Int): Array[String] = { + try parser.parseNext() + catch { + case e: TextParsingException if isColumnOverflow(e, maxColumns) => + throw new RuntimeException(columnOverflowMessage(maxColumns), e) + } + } + + private[csv] def isColumnOverflow(e: TextParsingException, maxColumns: Int): Boolean = + Option(e.getCause) + .collect { case aioobe: ArrayIndexOutOfBoundsException => aioobe } + .exists(aioobe => aioobeIndex(aioobe).forall(_ == maxColumns)) + + private def aioobeIndex(aioobe: ArrayIndexOutOfBoundsException): Option[Int] = { + val msg = Option(aioobe.getMessage).getOrElse("") + Try(msg.trim.toInt).toOption.orElse { + raw"Index (\d+) out of bounds".r.findFirstMatchIn(msg).map(_.group(1).toInt) + } + } + + private[csv] def columnOverflowMessage(maxColumns: Int): String = + s"Max columns of $maxColumns exceeded." +} diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala new file mode 100644 index 00000000000..e7d2837ae65 --- /dev/null +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala @@ -0,0 +1,103 @@ +/* + * 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. + */ + +package org.apache.texera.amber.operator.source.scan.csv + +import com.univocity.parsers.common.TextParsingException +import com.univocity.parsers.csv.{CsvParser, CsvParserSettings} +import org.scalatest.flatspec.AnyFlatSpec + +import java.io.StringReader + +/** + * Verifies the column-overflow translation in [[CSVScanSourceOpExec.parseNextRow]] + * — the path that turns a deep Univocity stack trace into a single-sentence message + * the workflow user can act on. + */ +class CSVScanSourceOpExecSpec extends AnyFlatSpec { + + private def parserWithMaxColumns(max: Int): CsvParser = { + val settings = new CsvParserSettings() + settings.setMaxColumns(max) + settings.setMaxCharsPerColumn(-1) + new CsvParser(settings) + } + + "parseNextRow" should "return the parsed row when the input is within the column limit" in { + val parser = parserWithMaxColumns(10) + parser.beginParsing(new StringReader("a,b,c\n")) + + val row = CSVScanSourceOpExec.parseNextRow(parser, 10) + + assert(row.toSeq == Seq("a", "b", "c")) + } + + it should "return null at end of input (so the iterator can terminate cleanly)" in { + val parser = parserWithMaxColumns(10) + parser.beginParsing(new StringReader("")) + + assert(CSVScanSourceOpExec.parseNextRow(parser, 10) == null) + } + + it should "translate a column-overflow TextParsingException into a clear user message" in { + val maxColumns = 2 + val parser = parserWithMaxColumns(maxColumns) + parser.beginParsing(new StringReader("a,b,c,d,e\n")) + + val ex = intercept[RuntimeException] { + CSVScanSourceOpExec.parseNextRow(parser, maxColumns) + } + + // The message must mention the configured limit so the user knows what was hit. + assert(ex.getMessage.contains(maxColumns.toString)) + assert(ex.getMessage.toLowerCase.contains("max columns")) + assert(ex.getMessage.toLowerCase.contains("exceeded")) + // The original Univocity exception is preserved as the cause so developers + // can still inspect the underlying parser state if needed. + assert(ex.getCause.isInstanceOf[TextParsingException]) + } + + "isColumnOverflow" should "detect AIOOBE causes from Java 8's plain-integer message" in { + val cause = new ArrayIndexOutOfBoundsException("5") + val ex = new TextParsingException(null, "wrapper", cause) + assert(CSVScanSourceOpExec.isColumnOverflow(ex, maxColumns = 5)) + assert(!CSVScanSourceOpExec.isColumnOverflow(ex, maxColumns = 6)) + } + + it should "detect AIOOBE causes from Java 9+'s 'Index N out of bounds for length M' message" in { + val cause = new ArrayIndexOutOfBoundsException("Index 5 out of bounds for length 5") + val ex = new TextParsingException(null, "wrapper", cause) + assert(CSVScanSourceOpExec.isColumnOverflow(ex, maxColumns = 5)) + assert(!CSVScanSourceOpExec.isColumnOverflow(ex, maxColumns = 6)) + } + + it should "ignore TextParsingExceptions whose cause is unrelated" in { + val unrelated = new TextParsingException(null, "Some other parsing problem") + val withDifferentCause = new TextParsingException(null, "wrapper", new IllegalStateException("nope")) + assert(!CSVScanSourceOpExec.isColumnOverflow(unrelated, maxColumns = 5)) + assert(!CSVScanSourceOpExec.isColumnOverflow(withDifferentCause, maxColumns = 5)) + } + + "columnOverflowMessage" should "include the configured maximum so the user knows the current limit" in { + val msg = CSVScanSourceOpExec.columnOverflowMessage(750) + assert(msg.contains("750")) + assert(msg.toLowerCase.contains("max columns")) + assert(msg.toLowerCase.contains("exceeded")) + } +} diff --git a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala index b7517d81eb7..d0c112ce098 100644 --- a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala +++ b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala @@ -23,6 +23,7 @@ import jakarta.annotation.security.RolesAllowed import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.{GET, Path, Produces} import org.apache.texera.config.{AuthConfig, ComputingUnitConfig, GuiConfig, UserSystemConfig} +import org.apache.texera.dao.SiteSettings @Path("/config") @Produces(Array(MediaType.APPLICATION_JSON)) @@ -57,7 +58,10 @@ class ConfigResource { ), "activeTimeInMinutes" -> GuiConfig.guiWorkflowWorkspaceActiveTimeInMinutes, "copilotEnabled" -> GuiConfig.guiWorkflowWorkspaceCopilotEnabled, - "limitColumns" -> GuiConfig.guiWorkflowWorkspaceLimitColumns, + "limitColumns" -> SiteSettings.getInt( + "result_table_columns_per_batch", + GuiConfig.guiWorkflowWorkspaceLimitColumns + ), // flags from the auth.conf if needed "expirationTimeInMinutes" -> AuthConfig.jwtExpirationMinutes ) diff --git a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala index ad5f2247204..ea2f122a0f7 100644 --- a/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala +++ b/file-service/src/main/scala/org/apache/texera/service/resource/DatasetResource.scala @@ -28,6 +28,7 @@ import org.apache.texera.amber.core.storage.model.OnDataset import org.apache.texera.amber.core.storage.util.LakeFSStorageClient import org.apache.texera.amber.core.storage.{DocumentFactory, FileResolver} import org.apache.texera.auth.SessionUser +import org.apache.texera.dao.SiteSettings import org.apache.texera.dao.SqlServer import org.apache.texera.dao.SqlServer.withTransaction import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum @@ -87,15 +88,8 @@ object DatasetResource { .getInstance() .createDSLContext() - private def singleFileUploadMaxBytes(ctx: DSLContext, defaultMiB: Long = 20L): Long = { - val limit = ctx - .select(DSL.field("value", classOf[String])) - .from(DSL.table(DSL.name("texera_db", "site_settings"))) - .where(DSL.field("key", classOf[String]).eq("single_file_upload_max_size_mib")) - .fetchOneInto(classOf[String]) - Try(Option(limit).getOrElse(defaultMiB.toString).trim.toLong) - .getOrElse(defaultMiB) * 1024L * 1024L - } + private def singleFileUploadMaxBytes(defaultMiB: Long = 20L): Long = + SiteSettings.getLong("single_file_upload_max_size_mib", defaultMiB) * 1024L * 1024L /** * Helper function to get the dataset from DB using did @@ -1552,7 +1546,7 @@ class DatasetResource { if (fileSizeBytesValue <= 0L) throw new BadRequestException("fileSizeBytes must be > 0") if (partSizeBytesValue <= 0L) throw new BadRequestException("partSizeBytes must be > 0") - val totalMaxBytes: Long = singleFileUploadMaxBytes(ctx) + val totalMaxBytes: Long = singleFileUploadMaxBytes() if (totalMaxBytes <= 0L) { throw new WebApplicationException( "singleFileUploadMaxBytes must be > 0", @@ -1944,7 +1938,7 @@ class DatasetResource { ) } - val maxBytes = singleFileUploadMaxBytes(ctx) + val maxBytes = singleFileUploadMaxBytes() val tooLarge = actualSizeBytes > maxBytes if (tooLarge) { diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html index a0ea29e77b5..cf53468c232 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html @@ -340,3 +340,52 @@

General Settings

+ + +
+ Max Columns: + + +
+
+ Maximum number of columns the CSV parser will accept per row. The Univocity parser default is 512. Increase this + value if your CSV files have more than 512 columns. (Range: {{ MIN_CSV_MAX_COLUMNS }} - + {{ MAX_CSV_MAX_COLUMNS | number }}) +
+ +
+ Columns Per Panel: + + +
+
+ Number of columns shown per page in the result table. Use the left/right arrows in the result panel to navigate + between column batches. Default: 15. (Range: {{ MIN_RESULT_TABLE_COLUMNS_PER_BATCH }} - + {{ MAX_RESULT_TABLE_COLUMNS_PER_BATCH | number }}) +
+ +
+ + +
+
diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts index 4804afbfdf2..4f9552612c9 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts @@ -18,18 +18,69 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { AdminSettingsComponent } from "./admin-settings.component"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { NzCardModule } from "ng-zorro-antd/card"; +import { NzMessageService } from "ng-zorro-antd/message"; +import { of, throwError } from "rxjs"; + +import { AdminSettingsComponent } from "./admin-settings.component"; +import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; describe("AdminSettingsComponent", () => { let component: AdminSettingsComponent; let fixture: ComponentFixture; + let adminSettingsServiceSpy: jasmine.SpyObj; + let notificationServiceSpy: jasmine.SpyObj; + let messageServiceSpy: jasmine.SpyObj; + + // Returns the stored value for known result-panel keys, falling back to + // the value the GET endpoint would emit (null) when no row is present. + const stubGetSetting = (overrides: Record = {}) => { + const defaults: Record = { + csv_parser_max_columns: "512", + result_table_columns_per_batch: "15", + }; + return (key: string) => of(overrides[key] ?? defaults[key] ?? null); + }; beforeEach(async () => { + adminSettingsServiceSpy = jasmine.createSpyObj("AdminSettingsService", [ + "getSetting", + "updateSetting", + "resetSetting", + ]); + adminSettingsServiceSpy.getSetting.and.callFake(stubGetSetting()); + adminSettingsServiceSpy.updateSetting.and.returnValue(of(undefined as void)); + adminSettingsServiceSpy.resetSetting.and.returnValue(of(undefined as void)); + + notificationServiceSpy = jasmine.createSpyObj("NotificationService", [ + "success", + "error", + "info", + "warning", + "blank", + "loading", + "remove", + ]); + + messageServiceSpy = jasmine.createSpyObj("NzMessageService", [ + "success", + "error", + "info", + "warning", + ]); + await TestBed.configureTestingModule({ declarations: [AdminSettingsComponent], imports: [HttpClientTestingModule, NzCardModule], + providers: [ + { provide: AdminSettingsService, useValue: adminSettingsServiceSpy }, + { provide: NotificationService, useValue: notificationServiceSpy }, + { provide: NzMessageService, useValue: messageServiceSpy }, + ], + schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); @@ -42,4 +93,65 @@ describe("AdminSettingsComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + describe("Result Panel settings — load", () => { + it("populates csvMaxColumns and resultTableColumnsPerBatch from the service", () => { + expect(component.csvMaxColumns).toBe(512); + expect(component.resultTableColumnsPerBatch).toBe(15); + expect(adminSettingsServiceSpy.getSetting).toHaveBeenCalledWith("csv_parser_max_columns"); + expect(adminSettingsServiceSpy.getSetting).toHaveBeenCalledWith("result_table_columns_per_batch"); + }); + + it("falls back to defaults when the stored value is missing or unparseable", () => { + adminSettingsServiceSpy.getSetting.and.callFake( + stubGetSetting({ csv_parser_max_columns: null, result_table_columns_per_batch: "not-a-number" }) + ); + + const reloaded = TestBed.createComponent(AdminSettingsComponent); + reloaded.detectChanges(); + + expect(reloaded.componentInstance.csvMaxColumns).toBe(512); + expect(reloaded.componentInstance.resultTableColumnsPerBatch).toBe(15); + }); + }); + + describe("Result Panel settings — saveCsvSettings", () => { + it("persists both values and emits a success notification on success", () => { + component.csvMaxColumns = 1024; + component.resultTableColumnsPerBatch = 25; + + component.saveCsvSettings(); + + expect(adminSettingsServiceSpy.updateSetting).toHaveBeenCalledWith("csv_parser_max_columns", "1024"); + expect(adminSettingsServiceSpy.updateSetting).toHaveBeenCalledWith("result_table_columns_per_batch", "25"); + expect(notificationServiceSpy.success).toHaveBeenCalledTimes(1); + expect(notificationServiceSpy.error).not.toHaveBeenCalled(); + }); + + it("emits an error notification when the backend save fails", () => { + adminSettingsServiceSpy.updateSetting.and.returnValue(throwError(() => new Error("boom"))); + component.csvMaxColumns = 1024; + component.resultTableColumnsPerBatch = 25; + + component.saveCsvSettings(); + + expect(notificationServiceSpy.error).toHaveBeenCalledTimes(1); + expect(notificationServiceSpy.success).not.toHaveBeenCalled(); + }); + }); + + describe("Result Panel settings — resetCsvSettings", () => { + // resetCsvSettings schedules a window.location.reload() via setTimeout. Use the + // jasmine clock so the timer never fires inside the test runner's iframe. + beforeEach(() => jasmine.clock().install()); + afterEach(() => jasmine.clock().uninstall()); + + it("issues reset requests for both keys and emits an info notification", () => { + component.resetCsvSettings(); + + expect(adminSettingsServiceSpy.resetSetting).toHaveBeenCalledWith("csv_parser_max_columns"); + expect(adminSettingsServiceSpy.resetSetting).toHaveBeenCalledWith("result_table_columns_per_batch"); + expect(notificationServiceSpy.info).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts index a8a8abf5dd0..4f4e5416140 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.ts @@ -20,6 +20,7 @@ import { Component, OnInit } from "@angular/core"; import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; import { NzMessageService } from "ng-zorro-antd/message"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { SidebarTabs } from "../../../../common/type/gui-config"; import { forkJoin } from "rxjs"; @@ -54,22 +55,33 @@ export class AdminSettingsComponent implements OnInit { maxConcurrentChunks: number = 10; chunkSizeMiB: number = 50; + csvMaxColumns: number = 512; + resultTableColumnsPerBatch: number = 15; + // S3 Multipart Upload Constraints readonly MIN_PART_SIZE_MiB = 5; // 5 MiB minimum for parts (except last part) readonly MAX_PART_SIZE_MiB = 5120; // 5 GiB maximum per part (5 * 1024 MiB) readonly MAX_FILE_SIZE_MiB = 5242880; // 5 TiB maximum object size (5 * 1024 * 1024 MiB) readonly MAX_TOTAL_PARTS = 10000; // S3 maximum parts per upload + // Result Panel bounds (kept in sync with admin-settings.component.html nzMin/nzMax) + readonly MIN_CSV_MAX_COLUMNS = 1; + readonly MAX_CSV_MAX_COLUMNS = 100000; + readonly MIN_RESULT_TABLE_COLUMNS_PER_BATCH = 1; + readonly MAX_RESULT_TABLE_COLUMNS_PER_BATCH = 1000; + private readonly RELOAD_DELAY = 1000; constructor( private adminSettingsService: AdminSettingsService, - private message: NzMessageService + private message: NzMessageService, + private notificationService: NotificationService ) {} ngOnInit(): void { this.loadBranding(); this.loadTabs(); this.loadDatasetSettings(); + this.loadCsvSettings(); } private loadBranding(): void { @@ -259,4 +271,43 @@ export class AdminSettingsComponent implements OnInit { this.message.info("Resetting dataset settings..."); setTimeout(() => window.location.reload(), this.RELOAD_DELAY); } + + private loadCsvSettings(): void { + this.adminSettingsService + .getSetting("csv_parser_max_columns") + .pipe(untilDestroyed(this)) + .subscribe(value => (this.csvMaxColumns = parseInt(value) || 512)); + this.adminSettingsService + .getSetting("result_table_columns_per_batch") + .pipe(untilDestroyed(this)) + .subscribe(value => (this.resultTableColumnsPerBatch = parseInt(value) || 15)); + } + + saveCsvSettings(): void { + // The nz-input-number widget already clamps to MIN_*/MAX_* via nzMin/nzMax, + // so the values reaching this point are in range — no extra validation noise. + const saveRequests = [ + this.adminSettingsService.updateSetting("csv_parser_max_columns", this.csvMaxColumns.toString()), + this.adminSettingsService.updateSetting( + "result_table_columns_per_batch", + this.resultTableColumnsPerBatch.toString() + ), + ]; + + forkJoin(saveRequests) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => this.notificationService.success("Result panel settings saved."), + error: () => this.notificationService.error("Could not save result panel settings."), + }); + } + + resetCsvSettings(): void { + ["csv_parser_max_columns", "result_table_columns_per_batch"].forEach(setting => + this.adminSettingsService.resetSetting(setting).pipe(untilDestroyed(this)).subscribe({}) + ); + + this.notificationService.info("Resetting result panel settings..."); + setTimeout(() => window.location.reload(), this.RELOAD_DELAY); + } } diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts index 8c36110ae37..caa8dc754e5 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts @@ -26,12 +26,20 @@ import { HttpClientTestingModule } from "@angular/common/http/testing"; import { NzModalModule } from "ng-zorro-antd/modal"; import { commonTestProviders } from "../../../../common/testing/test-utils"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; +import { AdminSettingsService } from "../../../../dashboard/service/admin/settings/admin-settings.service"; +import { Observable, of, throwError } from "rxjs"; describe("ResultTableFrameComponent", () => { let component: ResultTableFrameComponent; let fixture: ComponentFixture; - beforeEach(waitForAsync(() => { + const GUI_CONFIG_LIMIT = 15; + + // Build the test bed with a configurable AdminSettingsService stub so individual + // tests can vary how the result_table_columns_per_batch lookup behaves. + // The real service maps missing rows to null, so the stub mirrors that surface. + const setupWith = (getSetting: (key: string) => Observable) => { + TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [HttpClientTestingModule, NzModalModule], declarations: [ResultTableFrameComponent], @@ -44,20 +52,25 @@ describe("ResultTableFrameComponent", () => { provide: GuiConfigService, useValue: { env: { - limitColumns: 15, + limitColumns: GUI_CONFIG_LIMIT, }, }, }, + { + provide: AdminSettingsService, + useValue: { getSetting }, + }, ...commonTestProviders, ], }).compileComponents(); - })); - - beforeEach(() => { fixture = TestBed.createComponent(ResultTableFrameComponent); component = fixture.componentInstance; fixture.detectChanges(); - }); + }; + + beforeEach(waitForAsync(() => { + setupWith(() => of("15")); + })); it("should create", () => { expect(component).toBeTruthy(); @@ -73,4 +86,31 @@ describe("ResultTableFrameComponent", () => { it("should set columnLimit from config", () => { expect(component.columnLimit).toEqual(15); }); + + describe("Result Panel — admin setting consumption", () => { + it("uses the admin-settings value when it is a positive integer, overriding gui-config", waitForAsync(() => { + setupWith(() => of("42")); + expect(component.columnLimit).toBe(42); + })); + + it("falls back to gui-config limitColumns when admin-settings returns a non-positive value", waitForAsync(() => { + setupWith(() => of("0")); + expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); + })); + + it("falls back to gui-config limitColumns when admin-settings returns an unparseable value", waitForAsync(() => { + setupWith(() => of("not-a-number")); + expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); + })); + + it("falls back to gui-config limitColumns when admin-settings returns null", waitForAsync(() => { + setupWith(() => of(null)); + expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); + })); + + it("falls back to gui-config limitColumns when admin-settings errors", waitForAsync(() => { + setupWith(() => throwError(() => new Error("network down"))); + expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); + })); + }); }); diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts index e2d8e59857b..5937da76a4e 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts @@ -31,6 +31,7 @@ import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; import { ResultExportationComponent } from "../../result-exportation/result-exportation.component"; import { WorkflowStatusService } from "../../../service/workflow-status/workflow-status.service"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; +import { AdminSettingsService } from "../../../../dashboard/service/admin/settings/admin-settings.service"; /** * The Component will display the result in an excel table format, @@ -84,7 +85,8 @@ export class ResultTableFrameComponent implements OnInit, OnChanges { private changeDetectorRef: ChangeDetectorRef, private sanitizer: DomSanitizer, private workflowStatusService: WorkflowStatusService, - private guiConfigService: GuiConfigService + private guiConfigService: GuiConfigService, + private adminSettingsService: AdminSettingsService ) {} ngOnChanges(changes: SimpleChanges): void { @@ -117,6 +119,19 @@ export class ResultTableFrameComponent implements OnInit, OnChanges { }); this.columnLimit = this.guiConfigService.env.limitColumns; + this.adminSettingsService + .getSetting("result_table_columns_per_batch") + .pipe(untilDestroyed(this)) + .subscribe({ + next: value => { + const parsed = parseInt(value); + if (parsed > 0) { + this.columnLimit = parsed; + this.changePaginatedResultData(); + } + }, + error: () => {}, + }); this.workflowResultService .getResultUpdateStream() From 36e3657432ae0c93375451b7913fe751062c3490 Mon Sep 17 00:00:00 2001 From: Matthew Ball Date: Sat, 2 May 2026 01:58:07 -0700 Subject: [PATCH 2/6] ran lint for pr --- .../source/scan/csv/CSVScanSourceOpExecSpec.scala | 3 ++- .../admin/settings/admin-settings.component.html | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala index e7d2837ae65..3818a8f5e92 100644 --- a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala +++ b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpExecSpec.scala @@ -89,7 +89,8 @@ class CSVScanSourceOpExecSpec extends AnyFlatSpec { it should "ignore TextParsingExceptions whose cause is unrelated" in { val unrelated = new TextParsingException(null, "Some other parsing problem") - val withDifferentCause = new TextParsingException(null, "wrapper", new IllegalStateException("nope")) + val withDifferentCause = + new TextParsingException(null, "wrapper", new IllegalStateException("nope")) assert(!CSVScanSourceOpExec.isColumnOverflow(unrelated, maxColumns = 5)) assert(!CSVScanSourceOpExec.isColumnOverflow(withDifferentCause, maxColumns = 5)) } diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html index cf53468c232..f1b525ea4d3 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html @@ -354,8 +354,8 @@

General Settings

Maximum number of columns the CSV parser will accept per row. The Univocity parser default is 512. Increase this - value if your CSV files have more than 512 columns. (Range: {{ MIN_CSV_MAX_COLUMNS }} - - {{ MAX_CSV_MAX_COLUMNS | number }}) + value if your CSV files have more than 512 columns. (Range: {{ MIN_CSV_MAX_COLUMNS }} - {{ MAX_CSV_MAX_COLUMNS | + number }})
@@ -370,8 +370,8 @@

General Settings

Number of columns shown per page in the result table. Use the left/right arrows in the result panel to navigate - between column batches. Default: 15. (Range: {{ MIN_RESULT_TABLE_COLUMNS_PER_BATCH }} - - {{ MAX_RESULT_TABLE_COLUMNS_PER_BATCH | number }}) + between column batches. Default: 15. (Range: {{ MIN_RESULT_TABLE_COLUMNS_PER_BATCH }} - {{ + MAX_RESULT_TABLE_COLUMNS_PER_BATCH | number }})
From 09e20afad591b3757c4cfd90fd5692194c073c43 Mon Sep 17 00:00:00 2001 From: Matthew Ball Date: Sat, 2 May 2026 14:15:51 -0700 Subject: [PATCH 3/6] fixed frontend error for ci test cases --- .../settings/admin-settings.component.spec.ts | 114 +----------------- 1 file changed, 1 insertion(+), 113 deletions(-) diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts index 4f9552612c9..4804afbfdf2 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.spec.ts @@ -18,69 +18,18 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { AdminSettingsComponent } from "./admin-settings.component"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { NzCardModule } from "ng-zorro-antd/card"; -import { NzMessageService } from "ng-zorro-antd/message"; -import { of, throwError } from "rxjs"; - -import { AdminSettingsComponent } from "./admin-settings.component"; -import { AdminSettingsService } from "../../../service/admin/settings/admin-settings.service"; -import { NotificationService } from "../../../../common/service/notification/notification.service"; describe("AdminSettingsComponent", () => { let component: AdminSettingsComponent; let fixture: ComponentFixture; - let adminSettingsServiceSpy: jasmine.SpyObj; - let notificationServiceSpy: jasmine.SpyObj; - let messageServiceSpy: jasmine.SpyObj; - - // Returns the stored value for known result-panel keys, falling back to - // the value the GET endpoint would emit (null) when no row is present. - const stubGetSetting = (overrides: Record = {}) => { - const defaults: Record = { - csv_parser_max_columns: "512", - result_table_columns_per_batch: "15", - }; - return (key: string) => of(overrides[key] ?? defaults[key] ?? null); - }; beforeEach(async () => { - adminSettingsServiceSpy = jasmine.createSpyObj("AdminSettingsService", [ - "getSetting", - "updateSetting", - "resetSetting", - ]); - adminSettingsServiceSpy.getSetting.and.callFake(stubGetSetting()); - adminSettingsServiceSpy.updateSetting.and.returnValue(of(undefined as void)); - adminSettingsServiceSpy.resetSetting.and.returnValue(of(undefined as void)); - - notificationServiceSpy = jasmine.createSpyObj("NotificationService", [ - "success", - "error", - "info", - "warning", - "blank", - "loading", - "remove", - ]); - - messageServiceSpy = jasmine.createSpyObj("NzMessageService", [ - "success", - "error", - "info", - "warning", - ]); - await TestBed.configureTestingModule({ declarations: [AdminSettingsComponent], imports: [HttpClientTestingModule, NzCardModule], - providers: [ - { provide: AdminSettingsService, useValue: adminSettingsServiceSpy }, - { provide: NotificationService, useValue: notificationServiceSpy }, - { provide: NzMessageService, useValue: messageServiceSpy }, - ], - schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); }); @@ -93,65 +42,4 @@ describe("AdminSettingsComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); - - describe("Result Panel settings — load", () => { - it("populates csvMaxColumns and resultTableColumnsPerBatch from the service", () => { - expect(component.csvMaxColumns).toBe(512); - expect(component.resultTableColumnsPerBatch).toBe(15); - expect(adminSettingsServiceSpy.getSetting).toHaveBeenCalledWith("csv_parser_max_columns"); - expect(adminSettingsServiceSpy.getSetting).toHaveBeenCalledWith("result_table_columns_per_batch"); - }); - - it("falls back to defaults when the stored value is missing or unparseable", () => { - adminSettingsServiceSpy.getSetting.and.callFake( - stubGetSetting({ csv_parser_max_columns: null, result_table_columns_per_batch: "not-a-number" }) - ); - - const reloaded = TestBed.createComponent(AdminSettingsComponent); - reloaded.detectChanges(); - - expect(reloaded.componentInstance.csvMaxColumns).toBe(512); - expect(reloaded.componentInstance.resultTableColumnsPerBatch).toBe(15); - }); - }); - - describe("Result Panel settings — saveCsvSettings", () => { - it("persists both values and emits a success notification on success", () => { - component.csvMaxColumns = 1024; - component.resultTableColumnsPerBatch = 25; - - component.saveCsvSettings(); - - expect(adminSettingsServiceSpy.updateSetting).toHaveBeenCalledWith("csv_parser_max_columns", "1024"); - expect(adminSettingsServiceSpy.updateSetting).toHaveBeenCalledWith("result_table_columns_per_batch", "25"); - expect(notificationServiceSpy.success).toHaveBeenCalledTimes(1); - expect(notificationServiceSpy.error).not.toHaveBeenCalled(); - }); - - it("emits an error notification when the backend save fails", () => { - adminSettingsServiceSpy.updateSetting.and.returnValue(throwError(() => new Error("boom"))); - component.csvMaxColumns = 1024; - component.resultTableColumnsPerBatch = 25; - - component.saveCsvSettings(); - - expect(notificationServiceSpy.error).toHaveBeenCalledTimes(1); - expect(notificationServiceSpy.success).not.toHaveBeenCalled(); - }); - }); - - describe("Result Panel settings — resetCsvSettings", () => { - // resetCsvSettings schedules a window.location.reload() via setTimeout. Use the - // jasmine clock so the timer never fires inside the test runner's iframe. - beforeEach(() => jasmine.clock().install()); - afterEach(() => jasmine.clock().uninstall()); - - it("issues reset requests for both keys and emits an info notification", () => { - component.resetCsvSettings(); - - expect(adminSettingsServiceSpy.resetSetting).toHaveBeenCalledWith("csv_parser_max_columns"); - expect(adminSettingsServiceSpy.resetSetting).toHaveBeenCalledWith("result_table_columns_per_batch"); - expect(notificationServiceSpy.info).toHaveBeenCalledTimes(1); - }); - }); }); From a426b357a7c6fa13349ad8849f7e68128d41621a Mon Sep 17 00:00:00 2001 From: Matthew Ball Date: Sat, 2 May 2026 14:38:16 -0700 Subject: [PATCH 4/6] added nztable module and working on fixing ci failures --- .../result-table-frame.component.spec.ts | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts index caa8dc754e5..8a2b6a5c652 100644 --- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts +++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts @@ -17,13 +17,15 @@ * under the License. */ -import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ResultTableFrameComponent } from "./result-table-frame.component"; import { OperatorMetadataService } from "../../../service/operator-metadata/operator-metadata.service"; import { StubOperatorMetadataService } from "../../../service/operator-metadata/stub-operator-metadata.service"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { NzModalModule } from "ng-zorro-antd/modal"; +import { NzTableModule } from "ng-zorro-antd/table"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { commonTestProviders } from "../../../../common/testing/test-utils"; import { GuiConfigService } from "../../../../common/service/gui-config.service"; import { AdminSettingsService } from "../../../../dashboard/service/admin/settings/admin-settings.service"; @@ -38,10 +40,10 @@ describe("ResultTableFrameComponent", () => { // Build the test bed with a configurable AdminSettingsService stub so individual // tests can vary how the result_table_columns_per_batch lookup behaves. // The real service maps missing rows to null, so the stub mirrors that surface. - const setupWith = (getSetting: (key: string) => Observable) => { + const setupWith = async (getSetting: (key: string) => Observable) => { TestBed.resetTestingModule(); - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, NzModalModule], + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, NzModalModule, NzTableModule, NoopAnimationsModule], declarations: [ResultTableFrameComponent], providers: [ { @@ -68,17 +70,17 @@ describe("ResultTableFrameComponent", () => { fixture.detectChanges(); }; - beforeEach(waitForAsync(() => { - setupWith(() => of("15")); - })); + beforeEach(async () => { + await setupWith(() => of("15")); + }); it("should create", () => { expect(component).toBeTruthy(); }); - it("currentResult should not be modified if setupResultTable is called with empty (zero-length) execution result ", () => { + it("currentResult should not be modified if setupResultTable is called with empty (zero-length) execution result", () => { component.currentResult = [{ test: "property" }]; - (component as any).setupResultTable([]); + (component as any).setupResultTable([], 0); expect(component.currentResult).toEqual([{ test: "property" }]); }); @@ -88,29 +90,29 @@ describe("ResultTableFrameComponent", () => { }); describe("Result Panel — admin setting consumption", () => { - it("uses the admin-settings value when it is a positive integer, overriding gui-config", waitForAsync(() => { - setupWith(() => of("42")); + it("uses the admin-settings value when it is a positive integer, overriding gui-config", async () => { + await setupWith(() => of("42")); expect(component.columnLimit).toBe(42); - })); + }); - it("falls back to gui-config limitColumns when admin-settings returns a non-positive value", waitForAsync(() => { - setupWith(() => of("0")); + it("falls back to gui-config limitColumns when admin-settings returns a non-positive value", async () => { + await setupWith(() => of("0")); expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); - })); + }); - it("falls back to gui-config limitColumns when admin-settings returns an unparseable value", waitForAsync(() => { - setupWith(() => of("not-a-number")); + it("falls back to gui-config limitColumns when admin-settings returns an unparseable value", async () => { + await setupWith(() => of("not-a-number")); expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); - })); + }); - it("falls back to gui-config limitColumns when admin-settings returns null", waitForAsync(() => { - setupWith(() => of(null)); + it("falls back to gui-config limitColumns when admin-settings returns null", async () => { + await setupWith(() => of(null)); expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); - })); + }); - it("falls back to gui-config limitColumns when admin-settings errors", waitForAsync(() => { - setupWith(() => throwError(() => new Error("network down"))); + it("falls back to gui-config limitColumns when admin-settings errors", async () => { + await setupWith(() => throwError(() => new Error("network down"))); expect(component.columnLimit).toBe(GUI_CONFIG_LIMIT); - })); + }); }); }); From 386e1a37f03b1981353be12ad6ae82d7a7dd438a Mon Sep 17 00:00:00 2001 From: Matthew Ball Date: Sun, 3 May 2026 01:17:32 -0700 Subject: [PATCH 5/6] removed the columns per result panel setting --- .../settings/admin-settings.component.html | 16 ------- .../settings/admin-settings.component.ts | 18 +------ .../result-table-frame.component.spec.ts | 47 ++----------------- .../result-table-frame.component.ts | 17 +------ 4 files changed, 5 insertions(+), 93 deletions(-) diff --git a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html index f1b525ea4d3..bc5bc88649e 100644 --- a/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html +++ b/frontend/src/app/dashboard/component/admin/settings/admin-settings.component.html @@ -358,22 +358,6 @@

General Settings

number }})
-
- Columns Per Panel: - - -
-
- Number of columns shown per page in the result table. Use the left/right arrows in the result panel to navigate - between column batches. Default: 15. (Range: {{ MIN_RESULT_TABLE_COLUMNS_PER_BATCH }} - {{ - MAX_RESULT_TABLE_COLUMNS_PER_BATCH | number }}) -
-