This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-8644-00cf017d8f5e70a8070e24dbee0176ee0f1b45f6
in repository https://gitbox.apache.org/repos/asf/texera.git

commit b7993fbafac472d0ef1b87d86169978c42343ac4
Author: Suryaa Charan <[email protected]>
AuthorDate: Sat Oct 3 00:36:16 2026 +0000

    feat: add a delimiter picker with regex validation (#8644)
    
    ### What changes were proposed in this PR?
    
    Delimiter properties were plain text boxes. A stored tab looked empty,
    typing `\t` stopped at the backslash, and Unnest String accepted invalid
    or degenerate regexes without a word. This replaces them with a
    **delimiter picker**: common delimiters by name, plus a **Custom…**
    entry. Each operator picks a mode through a schema hint
    (`@JsonSchemaInject(json = UIWidget.UIWidgetCharDelimiter /
    UIWidgetRegexDelimiter)`).
    
    ```
    CSV scans (char)      Comma  Tab  Semicolon  Pipe  Space          | Custom… 
      (1 char; typed \t -> tab)
    Unnest String (regex) Comma  Tab  Semicolon  Pipe (\|)  Whitespace (\s+)  
New line | Custom regex… (validated)
    ```
    
    | Area | Change |
    |---|---|
    | Frontend | New formly type `delimiter`
    (`frontend/src/app/common/formly/delimiter/`). Works in the canvas
    property panel and the Form View. Delimiter errors show immediately,
    including for invalid values loaded from a saved workflow. |
    | Regex check (browser) | Must compile, must not match the empty string
    (`\|`, `\s*`), must not match every character (`.`). The last is
    **exact**: it tests every BMP character other than line breaks, stopping
    at the first miss, so `[a0-]` or `[^,]` pass. Patterns using Java-only
    syntax (inline flags, possessive quantifiers, `\p{..}`, `\x{..}`,
    `\Q..\E`, `\e`, `\h`, `\z`, …) are left to the backend, because
    JavaScript can't parse them or reads them differently. |
    | Regex check (backend) | `UnnestStringOpDesc` makes the same checks
    with Java's engine during schema propagation, so a bad pattern is a
    **compile error** instead of a runtime `PatternSyntaxException` or an
    empty result. |
    | CSV scans | `CSVFileScan`, `ParallelCSVFileScan`, `CSVOldFileScan` use
    the char-mode widget. `maxLength: 1` is kept. |
    
    **Saved workflows:** the stored value is still the delimiter itself, so
    existing workflows load unchanged and a stored tab shows as "Tab".
    
    **One intentional behavior change:** a saved Unnest String delimiter
    that matches the empty string or every character (e.g. `|`, `.`) now
    fails to compile. Previously it ran and silently split every character
    apart, or output only line breaks.
    
    #### Before / after
    
    <!-- drag comparison/C1-csv-stored-tab.png here -->
    <!-- drag comparison/C2-csv-typing-tab.png here -->
    <!-- drag comparison/C3-csv-choices.png here -->
    <!-- drag comparison/C4-csv-legacy-value.png here -->
    <!-- drag comparison/C5-unnest-choices.png here -->
    <!-- drag comparison/C6-unnest-pipe.png here -->
    <!-- drag comparison/C7-unnest-dot.png here -->
    <!-- drag comparison/C8-unnest-invalid.png here -->
    <!-- drag comparison/C9-unnest-compile.png here -->
    <img width="1124" height="425" alt="C1-csv-stored-tab"
    
src="https://github.com/user-attachments/assets/ab9ba44d-9598-4675-be5f-89f08325a576";
    />
    <img width="1124" height="425" alt="C2-csv-typing-tab"
    
src="https://github.com/user-attachments/assets/29aac797-7da7-4b52-a300-cd64e22c3810";
    />
    <img width="1124" height="1030" alt="C3-csv-choices"
    
src="https://github.com/user-attachments/assets/61fb0b71-3588-476c-9003-64cd2f0b9996";
    />
    <img width="1124" height="574" alt="C4-csv-legacy-value"
    
src="https://github.com/user-attachments/assets/098d5824-22a1-4e9f-b96c-5c42ed750fe5";
    />
    <img width="1124" height="1018" alt="C5-unnest-choices"
    
src="https://github.com/user-attachments/assets/babde8bb-faf3-4e0a-b46b-04da73496cdd";
    />
    <img width="1124" height="607" alt="C6-unnest-pipe"
    
src="https://github.com/user-attachments/assets/1d7bf753-a114-40b4-bdec-62676530a443";
    />
    <img width="1124" height="651" alt="C7-unnest-dot"
    
src="https://github.com/user-attachments/assets/e6157de3-5ff1-4eb1-80df-de1d9dd12da9";
    />
    <img width="1124" height="562" alt="C8-unnest-invalid"
    
src="https://github.com/user-attachments/assets/d370d8af-86a4-4aaf-ae75-f1a722af5335";
    />
    <img width="1884" height="750" alt="C9-unnest-compile"
    
src="https://github.com/user-attachments/assets/7de580fc-927a-4bdf-a5ba-43298c8fd48d";
    />
    
    
    
    ### Any related issues, documentation, discussions?
    
    Closes #8638
    
    ### How was this PR tested?
    
    **Automated.** Tests were written first and seen failing, then made to
    pass.
    
    | Suite | Result |
    |---|---|
    | Frontend, full (`ng test`) | 232 files, 6,376 passed |
    | Delimiter / formly specs (included above) | presets for both modes,
    custom-regex validation (valid, invalid, empty-match, every-character,
    Java-only syntax), edge cases, and a rendered test that feeds the
    backend's exact schema through formly-jsonschema |
    | `WorkflowOperator/test`, full | 2,602 passed |
    | New backend specs | each CSV preset read by all three CSV readers;
    Unnest String split with every regex preset and custom patterns;
    compile-time rejection with Java semantics |
    | Lint / format / types / build | eslint, prettier-eslint, `tsc`, `ng
    build`, scalafmt: clean |
    
    **Manual, in the browser.** Local stack (`bin/local-dev.sh up`), Chrome
    driven by Playwright, 1600×1000:
    1. Open a workflow with CSV File Scan and Unnest String, click each
    operator, and open the Delimiter picker.
    2. Pick each preset; choose Custom… and type `#`, `\t`, `ab`.
    3. For Unnest String, choose Custom regex… and type `[,;]\s*`, `(`,
    `[a-`, `a{2,1}`, `|`, `\s*`, `.`, `\.`, `,++`, then clear the field.
    4. Type `(?i)(` and `(?s).`: the browser leaves them to the backend, and
    the Result Panel shows the compile error.
    
    All 33 scripted checks pass. The before/after screenshots above come
    from this run and from the same flow on the base commit.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5.5)
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: suryaacharan <[email protected]>
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../operator/metadata/annotations/UIWidget.java    |   7 +
 .../source/scan/csv/CSVScanSourceOpDesc.scala      |   3 +-
 .../scan/csv/ParallelCSVScanSourceOpDesc.scala     |   3 +-
 .../scan/csvOld/CSVOldScanSourceOpDesc.scala       |   3 +-
 .../operator/unneststring/UnnestStringOpDesc.scala |  72 +++++-
 .../source/scan/csv/CSVScanSourceOpDescSpec.scala  |  90 ++++++++
 .../unneststring/UnnestStringOpDescSpec.scala      | 109 ++++++++-
 .../unneststring/UnnestStringOpExecSpec.scala      |  94 ++++++++
 .../formly/delimiter/delimiter-presets.spec.ts     | 243 +++++++++++++++++++++
 .../common/formly/delimiter/delimiter-presets.ts   | 139 ++++++++++++
 .../formly/delimiter/delimiter.rendered.spec.ts    | 209 ++++++++++++++++++
 .../common/formly/delimiter/delimiter.type.html    |  47 ++++
 .../common/formly/delimiter/delimiter.type.scss    |  17 +-
 .../common/formly/delimiter/delimiter.type.spec.ts | 227 +++++++++++++++++++
 .../app/common/formly/delimiter/delimiter.type.ts  | 135 ++++++++++++
 .../src/app/common/formly/formly-config.spec.ts    |  21 ++
 frontend/src/app/common/formly/formly-config.ts    |  28 +++
 17 files changed, 1433 insertions(+), 14 deletions(-)

diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/annotations/UIWidget.java
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/annotations/UIWidget.java
index 04bc6a05e6..ad8e00f440 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/annotations/UIWidget.java
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/annotations/UIWidget.java
@@ -23,6 +23,13 @@ public class UIWidget {
 
     public static final String UIWidgetTextArea = "{ \"widget\": {\n          
\"formlyConfig\": {\n            \"type\": \"textarea\",\n            
\"templateOptions\": {\n              \"autosize\": true,\n              
\"autosizeMinRows\": 3\n            }\n          }\n        }\n      }";
 
+    // A delimiter picker: common delimiters by name (so a tab is chosen, not 
typed), plus a
+    // free-form entry for anything else. "char" is one literal character; 
"regex" is a pattern
+    // the picker checks as it is typed.
+    public static final String UIWidgetCharDelimiter = "{ \"maxLength\": 1, 
\"widget\": {\n          \"formlyConfig\": {\n            \"type\": 
\"delimiter\",\n            \"props\": {\n              \"delimiterMode\": 
\"char\"\n            }\n          }\n        }\n      }";
+
+    public static final String UIWidgetRegexDelimiter = "{ \"widget\": {\n     
     \"formlyConfig\": {\n            \"type\": \"delimiter\",\n            
\"props\": {\n              \"delimiterMode\": \"regex\"\n            }\n       
   }\n        }\n      }";
+
     public static final String UIWidgetPassword = "{ \"widget\": {\n          
\"formlyConfig\": {\n            \"templateOptions\": {\n              
\"type\": \"password\"\n            }\n          }\n        }\n      }";
 
 }
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 38a3d18736..1ebbd10040 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
@@ -28,6 +28,7 @@ import 
org.apache.texera.amber.core.tuple.AttributeTypeUtils.inferSchemaFromRows
 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.metadata.annotations.UIWidget
 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
@@ -43,7 +44,7 @@ class CSVScanSourceOpDesc extends ScanSourceOpDesc {
   @JsonSchemaTitle("Delimiter")
   @JsonPropertyDescription("single character separating the fields on each 
line")
   @JsonInclude(JsonInclude.Include.NON_ABSENT)
-  @JsonSchemaInject(json = """{ "maxLength": 1 }""")
+  @JsonSchemaInject(json = UIWidget.UIWidgetCharDelimiter)
   var customDelimiter: Option[String] = None
 
   @JsonProperty(defaultValue = "true")
diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
index dda5cd3eec..4e6da0f5c4 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csv/ParallelCSVScanSourceOpDesc.scala
@@ -29,6 +29,7 @@ import 
org.apache.texera.amber.core.tuple.AttributeTypeUtils.inferSchemaFromRows
 import org.apache.texera.amber.core.tuple.{Attribute, 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.metadata.annotations.UIWidget
 import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc
 import org.apache.texera.amber.util.JSONUtils.objectMapper
 
@@ -42,7 +43,7 @@ class ParallelCSVScanSourceOpDesc extends ScanSourceOpDesc {
   @JsonSchemaTitle("Delimiter")
   @JsonPropertyDescription("single character separating the fields on each 
line")
   @JsonDeserialize(contentAs = classOf[java.lang.String])
-  @JsonSchemaInject(json = """{ "maxLength": 1 }""")
+  @JsonSchemaInject(json = UIWidget.UIWidgetCharDelimiter)
   var customDelimiter: Option[String] = None
 
   @JsonProperty(defaultValue = "true")
diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
index b8ec56e4df..5eb0b1303c 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/source/scan/csvOld/CSVOldScanSourceOpDesc.scala
@@ -28,6 +28,7 @@ import 
org.apache.texera.amber.core.tuple.AttributeTypeUtils.inferSchemaFromRows
 import org.apache.texera.amber.core.tuple.{Attribute, 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.metadata.annotations.UIWidget
 import org.apache.texera.amber.operator.source.scan.ScanSourceOpDesc
 import org.apache.texera.amber.util.JSONUtils.objectMapper
 
@@ -40,7 +41,7 @@ class CSVOldScanSourceOpDesc extends ScanSourceOpDesc {
   @JsonProperty(defaultValue = ",")
   @JsonSchemaTitle("Delimiter")
   @JsonPropertyDescription("single character separating the fields on each 
line")
-  @JsonSchemaInject(json = """{ "maxLength": 1 }""")
+  @JsonSchemaInject(json = UIWidget.UIWidgetCharDelimiter)
   var customDelimiter: Option[String] = Some(",")
 
   @JsonProperty(defaultValue = "true")
diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDesc.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDesc.scala
index fa2e5c2151..cb7f9f21b2 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDesc.scala
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDesc.scala
@@ -20,6 +20,7 @@
 package org.apache.texera.amber.operator.unneststring
 
 import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
+import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaInject
 import org.apache.texera.amber.core.executor.OpExecWithClassName
 import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
 import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, 
WorkflowIdentity}
@@ -33,13 +34,20 @@ import org.apache.texera.amber.core.workflow.{
 import org.apache.texera.amber.operator.{StandaloneCodeGenerator, 
StandaloneHelpers}
 import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral
 import org.apache.texera.amber.operator.flatmap.FlatMapOpDesc
-import 
org.apache.texera.amber.operator.metadata.annotations.{AutofillAttributeName, 
SampleColumn}
+import org.apache.texera.amber.operator.metadata.annotations.{
+  AutofillAttributeName,
+  SampleColumn,
+  UIWidget
+}
 import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, 
OperatorInfo}
 import org.apache.texera.amber.util.JSONUtils.objectMapper
 
+import java.util.regex.{Pattern, PatternSyntaxException}
+
 class UnnestStringOpDesc extends FlatMapOpDesc with StandaloneCodeGenerator {
   @JsonProperty(value = "Delimiter", required = true, defaultValue = ",")
-  @JsonPropertyDescription("string that separates the data")
+  @JsonPropertyDescription("regular expression that separates the data")
+  @JsonSchemaInject(json = UIWidget.UIWidgetRegexDelimiter)
   var delimiter: String = _
 
   @JsonProperty(value = "Attribute", required = true)
@@ -80,6 +88,7 @@ class UnnestStringOpDesc extends FlatMapOpDesc with 
StandaloneCodeGenerator {
       .withOutputPorts(operatorInfo.outputPorts)
       .withPropagateSchema(
         SchemaPropagationFunc(inputSchemas => {
+          validateDelimiter()
           val outputSchema = Option(resultAttribute)
             .filter(_.trim.nonEmpty)
             .map(attr => inputSchemas.values.head.add(attr, 
AttributeType.STRING))
@@ -89,6 +98,41 @@ class UnnestStringOpDesc extends FlatMapOpDesc with 
StandaloneCodeGenerator {
       )
   }
 
+  // Checked here, with Java's engine, so a bad pattern surfaces on the 
operator as a readable
+  // message while editing instead of as an exception or an empty result once 
the workflow
+  // runs. The picker checks the same things in the browser, but defers to 
this for syntax
+  // JavaScript can't parse or reads differently (inline flags, \p{..}).
+  private def validateDelimiter(): Unit = {
+    val source = Option(delimiter).getOrElse("")
+    if (source.isEmpty) {
+      throw new RuntimeException("Delimiter cannot be empty")
+    }
+    val pattern =
+      try Pattern.compile(source)
+      catch {
+        case e: PatternSyntaxException =>
+          throw new RuntimeException(
+            s"Delimiter is not a valid regular expression: 
${e.getDescription}" +
+              s" near index ${e.getIndex}"
+          )
+      }
+    if (pattern.matcher("").find()) {
+      throw new RuntimeException(
+        "Delimiter matches an empty string, so it would split between every 
character"
+      )
+    }
+    if (UnnestStringOpDesc.matchesEveryCharacter(pattern)) {
+      val literal =
+        if (source.length == 1) s""". To split on a literal "$source", use 
\\$source""" else ""
+      // A value's line breaks survive a pattern that leaves them out, so say 
what is left.
+      val whatIsLeft =
+        if (UnnestStringOpDesc.matchesLineBreaks(pattern))
+          "matches every character, so nothing would be left"
+        else "matches every character except line breaks, so only line breaks 
would be left"
+      throw new RuntimeException(s"Delimiter $whatIsLeft$literal")
+    }
+  }
+
   override def generateStandaloneCode(): String = 
generateStandaloneCode(Map.empty)
 
   override def generateStandaloneCode(inputSchemas: Map[PortIdentity, 
Schema]): String = {
@@ -117,3 +161,27 @@ class UnnestStringOpDesc extends FlatMapOpDesc with 
StandaloneCodeGenerator {
 
   override def standaloneHelpers(): Seq[String] = 
Seq(StandaloneHelpers.AttributeCasts)
 }
+
+object UnnestStringOpDesc {
+  // Line terminators, which `.` leaves out. Mirrors LINE_BREAKS in the 
frontend's
+  // delimiter-presets.ts.
+  private val LineBreaks: Seq[Int] = Seq(0x0a, 0x0d, 0x85, 0x2028, 0x2029)
+
+  private def matches(pattern: Pattern, code: Int): Boolean =
+    pattern.matcher(String.valueOf(code.toChar)).find()
+
+  /**
+    * Whether the pattern matches every character of the Basic Multilingual 
Plane other than line
+    * breaks (and lone surrogate halves, which are not characters on their 
own), each tested on
+    * its own. Exact rather than a sample, and cheap: a pattern that leaves 
any character behind
+    * stops at the first one it misses. Mirrors matchesEveryCharacter in the 
frontend's
+    * delimiter-presets.ts.
+    */
+  private def matchesEveryCharacter(pattern: Pattern): Boolean =
+    (0 to 0xffff).iterator
+      .filterNot(code => LineBreaks.contains(code) || code.toChar.isSurrogate)
+      .forall(matches(pattern, _))
+
+  private def matchesLineBreaks(pattern: Pattern): Boolean =
+    LineBreaks.forall(matches(pattern, _))
+}
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDescSpec.scala
index fe426552a9..f5122381f2 100644
--- 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDescSpec.scala
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/source/scan/csv/CSVScanSourceOpDescSpec.scala
@@ -288,6 +288,17 @@ class CSVScanSourceOpDescSpec extends AnyFlatSpec with 
BeforeAndAfter {
     }
   }
 
+  it should "render the delimiter as the single-character delimiter picker" in 
{
+    delimiterOwners.foreach {
+      case (name, opDescClass) =>
+        withClue(s"$name: ") {
+          val formlyConfig = 
delimiterSchema(opDescClass).path("widget").path("formlyConfig")
+          assert(formlyConfig.path("type").asText() == "delimiter")
+          assert(formlyConfig.path("props").path("delimiterMode").asText() == 
"char")
+        }
+    }
+  }
+
   it should "validate an empty or one-character delimiter and refuse a longer 
one" in {
     delimiterOwners.foreach {
       case (name, opDescClass) =>
@@ -304,6 +315,85 @@ class CSVScanSourceOpDescSpec extends AnyFlatSpec with 
BeforeAndAfter {
     }
   }
 
+  // The presets the delimiter picker offers for a CSV scan, exactly as it 
stores them.
+  private val pickerPresets: List[(String, String)] = List(
+    "comma" -> ",",
+    "tab" -> "\t",
+    "semicolon" -> ";",
+    "pipe" -> "|",
+    "space" -> " "
+  )
+
+  private def writeCsv(delimiter: String, body: String): String = {
+    val tmpFile = Files.createTempFile("delimited-", ".csv")
+    tmpFile.toFile.deleteOnExit()
+    Files.write(tmpFile, body.replace(",", 
delimiter).getBytes(StandardCharsets.UTF_8))
+    tmpFile.toString
+  }
+
+  private def allReaders(delimiter: String): List[ScanSourceOpDesc] = {
+    val csv = new CSVScanSourceOpDesc()
+    csv.customDelimiter = Some(delimiter)
+    val parallelCsv = new ParallelCSVScanSourceOpDesc()
+    parallelCsv.customDelimiter = Some(delimiter)
+    val oldCsv = new CSVOldScanSourceOpDesc()
+    oldCsv.customDelimiter = Some(delimiter)
+    List(csv, parallelCsv, oldCsv)
+  }
+
+  it should "read a file separated by each delimiter the picker offers" in {
+    pickerPresets.foreach {
+      case (name, delimiter) =>
+        val path = writeCsv(delimiter, "id,name,age\n1,Alice,30\n2,Bob,25\n")
+        allReaders(delimiter).foreach { reader =>
+          withClue(s"$name, ${reader.getClass.getSimpleName}: ") {
+            assert(columnNames(reader, path) == List("id", "name", "age"))
+          }
+        }
+    }
+  }
+
+  it should "accept each preset against the schema's one-character bound" in {
+    delimiterOwners.foreach {
+      case (owner, opDescClass) =>
+        pickerPresets.foreach {
+          case (name, delimiter) =>
+            withClue(s"$owner, $name: ") {
+              assert(schemaValidates(delimiterSchema(opDescClass), delimiter))
+            }
+        }
+    }
+  }
+
+  it should "read a custom non-ASCII delimiter" in {
+    val path = writeCsv("§", "id,name,age\n1,Alice,30\n")
+    allReaders("§").foreach { reader =>
+      withClue(s"${reader.getClass.getSimpleName}: ") {
+        assert(columnNames(reader, path) == List("id", "name", "age"))
+      }
+    }
+  }
+
+  it should "keep a quoted field whole when it contains the delimiter" in {
+    val tmpFile = Files.createTempFile("quoted-", ".csv")
+    tmpFile.toFile.deleteOnExit()
+    Files.write(tmpFile, "id;name\n1;\"Smith; 
J\"\n".getBytes(StandardCharsets.UTF_8))
+    allReaders(";").foreach { reader =>
+      withClue(s"${reader.getClass.getSimpleName}: ") {
+        assert(columnNames(reader, tmpFile.toString) == List("id", "name"))
+      }
+    }
+  }
+
+  it should "fall back to a comma when the delimiter is cleared" in {
+    val path = writeCsv(",", "id,name,age\n1,Alice,30\n")
+    allReaders("").foreach { reader =>
+      withClue(s"${reader.getClass.getSimpleName}: ") {
+        assert(columnNames(reader, path) == List("id", "name", "age"))
+      }
+    }
+  }
+
   it should "read a multi-character delimiter as its first character" in {
     // What the constraint gives up: a saved workflow holding a longer 
delimiter now
     // shows as invalid in the property editor. Its run is unchanged, which is 
what
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDescSpec.scala
index dc290e9947..74fde249a4 100644
--- 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDescSpec.scala
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpDescSpec.scala
@@ -24,7 +24,7 @@ import org.apache.texera.amber.core.tuple.{Attribute, 
AttributeType, Schema}
 import org.apache.texera.amber.core.virtualidentity.{ExecutionIdentity, 
WorkflowIdentity}
 import org.apache.texera.amber.core.workflow.PortIdentity
 import org.apache.texera.amber.operator.LogicalOp
-import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
+import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, 
OperatorMetadataGenerator}
 import org.apache.texera.amber.util.JSONUtils.objectMapper
 import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
@@ -83,6 +83,113 @@ class UnnestStringOpDescSpec extends AnyFlatSpec with 
Matchers {
     }
   }
 
+  it should "report a delimiter that is not a valid regular expression" in {
+    val op = newDesc("(", "tags", "tag")
+    val physical = op.getPhysicalOp(workflowId, executionId)
+    val input = Schema().add(new Attribute("tags", AttributeType.STRING))
+    val ex = intercept[RuntimeException] {
+      physical.propagateSchema.func(Map(op.operatorInfo.inputPorts.head.id -> 
input))
+    }
+    ex.getMessage should startWith("Delimiter is not a valid regular 
expression")
+  }
+
+  it should "name the problem for each common regex mistake" in {
+    val input = Schema().add(new Attribute("tags", AttributeType.STRING))
+    // unclosed group, unclosed class, nothing to repeat, reversed range, 
trailing escape
+    List("(", "[", "*", "a{2,1}", "\\").foreach { bad =>
+      withClue(s"pattern '$bad': ") {
+        val op = newDesc(bad, "tags", "tag")
+        val physical = op.getPhysicalOp(workflowId, executionId)
+        val ex = intercept[RuntimeException] {
+          physical.propagateSchema.func(Map(op.operatorInfo.inputPorts.head.id 
-> input))
+        }
+        ex.getMessage should startWith("Delimiter is not a valid regular 
expression")
+        ex.getMessage should include("near index")
+      }
+    }
+  }
+
+  private def compileError(delimiter: String): Option[String] = {
+    val op = newDesc(delimiter, "tags", "tag")
+    val input = Schema().add(new Attribute("tags", AttributeType.STRING))
+    try {
+      op.getPhysicalOp(workflowId, executionId)
+        .propagateSchema
+        .func(Map(op.operatorInfo.inputPorts.head.id -> input))
+      None
+    } catch { case e: RuntimeException => Some(e.getMessage) }
+  }
+
+  // Checked with Java's engine, so patterns the browser can't parse (inline 
flags) or reads
+  // differently (\p{..}) are judged by what the operator actually runs.
+  it should "reject a pattern that matches the empty string, which splits 
every character apart" in {
+    List("|", "\\s*", "(?i)", "(?s).*", "\\z", "\\Q\\E").foreach { pattern =>
+      withClue(s"pattern '$pattern': ") {
+        compileError(pattern).getOrElse("") should include("matches an empty 
string")
+      }
+    }
+  }
+
+  it should "reject a pattern that matches every character, line breaks 
included" in {
+    List("(?s).", "[\\s\\S]").foreach { pattern =>
+      withClue(s"pattern '$pattern': ") {
+        compileError(pattern).getOrElse("") should include(
+          "matches every character, so nothing would be left"
+        )
+      }
+    }
+  }
+
+  // Line breaks are what's left (see UnnestStringOpExecSpec), so the message 
says so rather
+  // than claiming nothing is.
+  it should "reject a pattern that matches every character but line breaks, 
naming what's left" in {
+    List(".", "[^\\n]", ".+").foreach { pattern =>
+      withClue(s"pattern '$pattern': ") {
+        compileError(pattern).getOrElse("") should include(
+          "matches every character except line breaks, so only line breaks 
would be left"
+        )
+      }
+    }
+  }
+
+  it should "suggest the escape for a lone metacharacter that matches 
everything" in {
+    compileError(".").getOrElse("") should include("\\.")
+  }
+
+  it should "reject an empty delimiter" in {
+    compileError("").getOrElse("") should include("cannot be empty")
+  }
+
+  it should "accept useful patterns that use Java-only syntax" in {
+    List("\\p{L}+", "(?i)and", ",++", "\\h*,\\h*", "\\Q|\\E", "\\s+").foreach 
{ pattern =>
+      withClue(s"pattern '$pattern': ") {
+        compileError(pattern) shouldBe None
+      }
+    }
+  }
+
+  it should "accept every preset the delimiter picker offers" in {
+    val input = Schema().add(new Attribute("tags", AttributeType.STRING))
+    List(",", "\\t", "\t", ";", "\\|", "\\s+", "\\n").foreach { preset =>
+      withClue(s"preset '$preset': ") {
+        val op = newDesc(preset, "tags", "tag")
+        val physical = op.getPhysicalOp(workflowId, executionId)
+        physical.propagateSchema.func(Map(op.operatorInfo.inputPorts.head.id 
-> input))
+      }
+    }
+  }
+
+  "UnnestStringOpDesc json schema" should "render the delimiter as the regex 
delimiter picker" in {
+    val formlyConfig = OperatorMetadataGenerator
+      .generateOperatorJsonSchema(classOf[UnnestStringOpDesc])
+      .path("properties")
+      .path("Delimiter")
+      .path("widget")
+      .path("formlyConfig")
+    formlyConfig.path("type").asText() shouldBe "delimiter"
+    formlyConfig.path("props").path("delimiterMode").asText() shouldBe "regex"
+  }
+
   // A hole widens an integer column to float, so the split would see "6.0" 
where
   // the engine splits "6". A real DOUBLE holding 6.0 looks the same and keeps 
its
   // point, so only the declared type can decide.
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpExecSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpExecSpec.scala
index 146d9c780e..57a97e86e2 100644
--- 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpExecSpec.scala
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/unneststring/UnnestStringOpExecSpec.scala
@@ -147,4 +147,98 @@ class UnnestStringOpExecSpec extends AnyFlatSpec with 
BeforeAndAfter {
     
assertThrows[java.util.NoSuchElementException](processedTuple.next().getField("split"))
     opExec.close()
   }
+
+  // Runs the operator over one value and returns the pieces it emits.
+  private def unnest(delimiter: String, value: String): List[String] = {
+    opDesc.attribute = "field1"
+    opDesc.delimiter = delimiter
+    val exec = new UnnestStringOpExec(objectMapper.writeValueAsString(opDesc))
+    // Built directly, not propagated: schema propagation now rejects the 
degenerate patterns
+    // (`.`, `|`) whose runtime behavior the tests below pin.
+    val schema = tupleSchema.add(new Attribute("split", AttributeType.STRING))
+    val input = Tuple
+      .builder(tupleSchema)
+      .add(new Attribute("field1", AttributeType.STRING), value)
+      .add(new Attribute("field2", AttributeType.INTEGER), 1)
+      .add(new Attribute("field3", AttributeType.STRING), "a")
+      .build()
+    exec.open()
+    val pieces = exec
+      .processTuple(input, 0)
+      
.map(_.asInstanceOf[SchemaEnforceable].enforceSchema(schema).getField[String]("split"))
+      .toList
+    exec.close()
+    pieces
+  }
+
+  // The presets the delimiter picker offers in regex mode, exactly as it 
stores them.
+  "UnnestStringOpExec with the picker's presets" should "split on a comma" in {
+    assert(unnest(",", "a,b,c") == List("a", "b", "c"))
+  }
+
+  it should "split on a tab written as the regex escape" in {
+    assert(unnest("\\t", "a\tb\tc") == List("a", "b", "c"))
+  }
+
+  it should "split on a tab stored as the literal character by an older 
workflow" in {
+    assert(unnest("\t", "a\tb\tc") == List("a", "b", "c"))
+  }
+
+  it should "split on a semicolon" in {
+    assert(unnest(";", "a;b;c") == List("a", "b", "c"))
+  }
+
+  it should "split on an escaped pipe" in {
+    assert(unnest("\\|", "a|b|c") == List("a", "b", "c"))
+  }
+
+  it should "split on any run of whitespace" in {
+    assert(unnest("\\s+", "a  b\t c\nd") == List("a", "b", "c", "d"))
+  }
+
+  it should "split on a new line written as the regex escape" in {
+    assert(unnest("\\n", "a\nb\nc") == List("a", "b", "c"))
+  }
+
+  "UnnestStringOpExec with a custom regex" should "split on a character class" 
in {
+    assert(unnest("[,;]", "a,b;c") == List("a", "b", "c"))
+  }
+
+  it should "absorb the spaces around a comma" in {
+    assert(unnest("\\s*,\\s*", "a , b,  c") == List("a", "b", "c"))
+  }
+
+  it should "split on a multi-character delimiter" in {
+    assert(unnest("::", "a::b::c") == List("a", "b", "c"))
+  }
+
+  it should "split on an escaped dot and keep the text between dots" in {
+    assert(unnest("\\.", "a.b.c") == List("a", "b", "c"))
+  }
+
+  it should "drop everything for an unescaped dot, which matches every 
character" in {
+    // Why the operator rejects it at compile time: every piece is empty.
+    assert(unnest(".", "a.b.c") == List())
+  }
+
+  it should "leave only the line breaks of a multi-line value for an unescaped 
dot" in {
+    assert(unnest(".", "ab\ncd\r\ne") == List("\n", "\r\n"))
+  }
+
+  it should "leave only the line breaks of a multi-line value for anything but 
a line feed" in {
+    assert(unnest("[^\\n]", "ab\ncd") == List("\n"))
+  }
+
+  it should "split every character apart for a bare pipe, the empty 
alternation" in {
+    // The operator rejects this pattern at compile time; this pins what it 
would have done.
+    assert(unnest("|", "abc") == List("a", "b", "c"))
+  }
+
+  it should "split on a non-ASCII delimiter" in {
+    assert(unnest("•", "a•b•c") == List("a", "b", "c"))
+  }
+
+  it should "accept Java-only syntax such as a possessive quantifier" in {
+    assert(unnest(",++", "a,,b,c") == List("a", "b", "c"))
+  }
 }
diff --git a/frontend/src/app/common/formly/delimiter/delimiter-presets.spec.ts 
b/frontend/src/app/common/formly/delimiter/delimiter-presets.spec.ts
new file mode 100644
index 0000000000..8ee505ef91
--- /dev/null
+++ b/frontend/src/app/common/formly/delimiter/delimiter-presets.spec.ts
@@ -0,0 +1,243 @@
+/**
+ * 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 { delimiterError, delimiterPresets, matchDelimiterPreset, 
unescapeCharDelimiter } from "./delimiter-presets";
+
+describe("char delimiter presets", () => {
+  // name shown -> the exact character a CSV scan's reader receives
+  const expected: [string, string][] = [
+    ["Comma", ","],
+    ["Tab", "\t"],
+    ["Semicolon", ";"],
+    ["Pipe", "|"],
+    ["Space", " "],
+  ];
+
+  it("offers exactly the common CSV delimiters, in order", () => {
+    expect(delimiterPresets("char").map(p => 
p.value)).toEqual(expected.map(([, value]) => value));
+  });
+
+  it.each(expected)("stores %s as the single character itself", (name, value) 
=> {
+    const preset = matchDelimiterPreset(value, "char")!;
+    expect(preset.label).toContain(name);
+    expect(preset.value).toHaveLength(1);
+  });
+
+  it("shows the tab escape in its label, since the character itself is 
invisible", () => {
+    expect(matchDelimiterPreset("\t", "char")!.label).toContain("\\t");
+  });
+
+  it.each([["#"], ["~"], ["§"], ["a"], [",;"], ["\\t"]])("treats %j as a 
custom value", value => {
+    expect(matchDelimiterPreset(value, "char")).toBeUndefined();
+  });
+
+  it.each([
+    ["\\t", "\t"],
+    ["\\\\", "\\"],
+  ])("reads the typed escape %j as the character it names", (typed, char) => {
+    expect(unescapeCharDelimiter(typed)).toBe(char);
+  });
+
+  it("leaves any other typed text as it is", () => {
+    expect(unescapeCharDelimiter("\\")).toBe("\\");
+    expect(unescapeCharDelimiter("#")).toBe("#");
+    expect(unescapeCharDelimiter("\\x")).toBe("\\x");
+  });
+
+  it("never reads a char delimiter as a pattern", () => {
+    for (const value of ["(", "[", "*", "|", "\\", "."]) {
+      expect(delimiterError(value, "char")).toBeUndefined();
+    }
+  });
+});
+
+describe("regex delimiter presets", () => {
+  // name shown -> stored pattern -> a value it must split into a, b, c
+  const expected: [string, string, string][] = [
+    ["Comma", ",", "a,b,c"],
+    ["Tab", "\\t", "a\tb\tc"],
+    ["Semicolon", ";", "a;b;c"],
+    ["Pipe", "\\|", "a|b|c"],
+    ["Whitespace", "\\s+", "a  b\t c"],
+    ["New line", "\\n", "a\nb\nc"],
+  ];
+
+  it("offers exactly the common delimiters, in order", () => {
+    expect(delimiterPresets("regex").map(p => 
p.value)).toEqual(expected.map(([, value]) => value));
+  });
+
+  it.each(expected)("stores %s as a pattern that splits on it", (name, 
pattern, sample) => {
+    const preset = matchDelimiterPreset(pattern, "regex")!;
+    expect(preset.label).toContain(name);
+    expect(delimiterError(pattern, "regex")).toBeUndefined();
+    expect(sample.split(new RegExp(pattern))).toEqual(["a", "b", "c"]);
+  });
+
+  it.each([
+    ["a literal tab", "\t", "\\t"],
+    ["a literal new line", "\n", "\\n"],
+  ])("names %s an older workflow stored as its preset", (_, stored, preset) => 
{
+    expect(matchDelimiterPreset(stored, "regex")!.value).toBe(preset);
+  });
+
+  it("does not show a bare pipe as the Pipe preset, since the two split 
differently", () => {
+    expect(matchDelimiterPreset("|", "regex")).toBeUndefined();
+  });
+
+  it("treats a space as a custom pattern rather than as Whitespace", () => {
+    expect(matchDelimiterPreset(" ", "regex")).toBeUndefined();
+  });
+});
+
+describe("custom regex validation", () => {
+  it.each([
+    ["a character class", "[,;]", "a,b;c"],
+    ["a comma with optional spaces", "\\s*,\\s*", "a , b,c"],
+    ["a multi-character delimiter", "::", "a::b::c"],
+    ["an escaped dot", "\\.", "a.b.c"],
+    ["an alternation", "(?:,|;)", "a,b;c"],
+    ["a run of digits", "\\d+", "a1b22c"],
+    ["a non-ASCII character", "•", "a•b•c"],
+    ["a space", " ", "a b c"],
+  ])("accepts %s", (_, pattern, sample) => {
+    expect(delimiterError(pattern, "regex")).toBeUndefined();
+    expect(sample.split(new RegExp(pattern))).toEqual(["a", "b", "c"]);
+  });
+
+  it.each([
+    ["an unclosed group", "("],
+    ["an unmatched closing parenthesis", ")"],
+    ["an unclosed character class", "["],
+    ["a quantifier with nothing to repeat", "*"],
+    ["a leading plus", "+"],
+    ["a reversed repetition range", "a{2,1}"],
+    ["a trailing backslash", "\\"],
+    ["an unfinished named group", "(?<"],
+    ["a reversed character range", "[z-a]"],
+  ])("rejects %s and says it is not a valid regular expression", (_, pattern) 
=> {
+    expect(delimiterError(pattern, "regex")).toMatch(/Invalid regular 
expression/);
+  });
+
+  it.each([
+    ["a bare pipe", "|"],
+    ["an optional run of whitespace", "\\s*"],
+    ["a starred character", "a*"],
+    ["an empty group", "(?:)"],
+    ["a start anchor", "^"],
+    ["an end anchor", "$"],
+    ["a match-anything pattern", ".*"],
+  ])("rejects %s, which matches an empty string and would split every 
character apart", (_, pattern) => {
+    expect(delimiterError(pattern, "regex")).toMatch(/matches an empty 
string/);
+  });
+
+  it("accepts a lookahead, which only matches before the delimiter and so 
keeps it on the next piece", () => {
+    expect(delimiterError("(?=,)", "regex")).toBeUndefined();
+    expect("a,b".split(/(?=,)/)).toEqual(["a", ",b"]);
+  });
+
+  it("leaves an empty or unset value to the required check", () => {
+    expect(delimiterError("", "regex")).toBeUndefined();
+    expect(delimiterError(null, "regex")).toBeUndefined();
+    expect(delimiterError(undefined, "regex")).toBeUndefined();
+  });
+
+  it("ignores a value that is not text", () => {
+    expect(delimiterError(44, "regex")).toBeUndefined();
+    expect(matchDelimiterPreset(44, "regex")).toBeUndefined();
+  });
+
+  // The browser's engine is not the operator's: Java accepts these, 
JavaScript throws on them. They are
+  // left to the backend, which compiles the pattern with Java 
(UnnestStringOpDescSpec) when the workflow
+  // compiles, rather than shown as a false error.
+  it.each([
+    ["a possessive quantifier", ",++"],
+    ["a possessive star", "\\s*+,"],
+    ["an atomic group", "(?>,|;)"],
+    ["an inline case-insensitive flag", "(?i)and"],
+    ["an inline comments flag", "(?x) , "],
+  ])("leaves %s, which only Java accepts, to the operator", (_, pattern) => {
+    expect(delimiterError(pattern, "regex")).toBeUndefined();
+  });
+
+  // JavaScript (without the `u` flag) parses these, but reads them as 
different patterns: `\\p{L}` as the
+  // literal text "p{L}", `\\h` as "h". Checking them in the browser gives 
wrong answers either way, so
+  // they are left to the operator, which checks them with Java's engine when 
the workflow compiles.
+  it.each([
+    ["a negated Unicode class, which JavaScript misreads as matching every 
character", "[^\\p{L}]"],
+    ["a Unicode letter class", "\\p{L}+"],
+    ["a POSIX class", "[^\\p{Alnum}]+"],
+    ["a Java character class", "\\p{javaWhitespace}+"],
+    ["a quoted literal", "\\Q|\\E"],
+    ["horizontal whitespace", "\\h*,\\h*"],
+    ["an end-of-input anchor", "\\z"],
+    ["a start-of-input anchor", "\\A,"],
+    ["a code-point escape", "\\x{1F600}"],
+    ["a negated code-point escape, which JavaScript misreads as matching every 
character", "[^\\x{2C}]"],
+    ["an escape-character escape, which JavaScript misreads as the letter e", 
"[^\\e]"],
+    ["a bell-character escape", "\\a"],
+    ["a named character", "\\N{COMMA}"],
+    ["vertical whitespace", "\\v+"],
+    ["an octal escape", "\\011"],
+  ])("leaves %s to the operator", (_, pattern) => {
+    expect(delimiterError(pattern, "regex")).toBeUndefined();
+  });
+
+  it("still checks an escaped backslash followed by p, which is not a Java 
class", () => {
+    expect(delimiterError("\\\\p|", "regex")).toMatch(/matches an empty 
string/);
+  });
+
+  it("still rejects a broken pattern that happens to contain Java-only 
syntax", () => {
+    expect(delimiterError("(?i)(", "regex")).toBeUndefined();
+    expect(delimiterError("(", "regex")).toMatch(/Invalid regular expression/);
+  });
+
+  it.each([
+    ["an unescaped dot", "."],
+    ["anything but a line feed", "[^\\n]"],
+    ["an unescaped dot, repeated", ".+"],
+  ])("rejects %s, which matches every character except line breaks, leaving 
only those", (_, pattern) => {
+    expect(delimiterError(pattern, "regex")).toMatch(
+      /matches every character except line breaks, so only line breaks would 
be left/
+    );
+  });
+
+  it("rejects a pattern matching every character, line breaks included, as 
leaving nothing", () => {
+    const error = delimiterError("[\\s\\S]", "regex");
+    expect(error).toMatch(/matches every character, so nothing would be left/);
+    expect(error).not.toContain("line breaks");
+  });
+
+  // Every character is tested, not a sample, so a pattern leaving any 
character behind is a working
+  // delimiter, however unusual.
+  it.each([
+    ["a class of many common characters, which still leaves the rest", 
'[aZé中09 \\t,;:|/._\\-"]', "a~b"],
+    ["a class of exactly the old probe characters", "[a0-]", "Hello, a-0 x"],
+    ["word characters and hyphens", "[\\w-]", "Hello, a-0 x"],
+    ["a run of lowercase letters, digits and hyphens", "[a-z0-9-]+", "Hello, 
a-0 x"],
+    ["everything but a comma", "[^,]", "a,b"],
+    ["any non-space character", "\\S", "a b"],
+  ])("accepts %s, which leaves some text", (_, pattern, sample) => {
+    expect(delimiterError(pattern, "regex")).toBeUndefined();
+    expect(sample.split(new RegExp(pattern)).some(piece => piece.length > 
0)).toBe(true);
+  });
+
+  it("suggests the escape when a lone dot was probably meant literally", () => 
{
+    expect(delimiterError(".", "regex")).toContain("\\.");
+  });
+});
diff --git a/frontend/src/app/common/formly/delimiter/delimiter-presets.ts 
b/frontend/src/app/common/formly/delimiter/delimiter-presets.ts
new file mode 100644
index 0000000000..01076efa5c
--- /dev/null
+++ b/frontend/src/app/common/formly/delimiter/delimiter-presets.ts
@@ -0,0 +1,139 @@
+/**
+ * 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.
+ */
+
+/**
+ * What a delimiter property holds: one literal character (the CSV scans, 
whose readers take a
+ * single Char) or a regular expression (Unnest String, which splits with 
`delimiter.r`).
+ */
+export type DelimiterMode = "char" | "regex";
+
+export interface DelimiterPreset {
+  label: string;
+  value: string;
+  /** Other stored values that mean the same delimiter, so an older workflow 
still shows it by name. */
+  aliases?: readonly string[];
+}
+
+/** The select option for "type your own". Only ever the select's value, never 
the stored one. */
+export const CUSTOM_DELIMITER = "__custom__";
+
+const CHAR_PRESETS: readonly DelimiterPreset[] = [
+  { label: "Comma ( , )", value: "," },
+  { label: "Tab ( \\t )", value: "\t" },
+  { label: "Semicolon ( ; )", value: ";" },
+  { label: "Pipe ( | )", value: "|" },
+  { label: "Space", value: " " },
+];
+
+// Regex values are written as patterns: a bare `|` is an empty alternation 
that splits between
+// every character, so the pipe preset escapes it.
+const REGEX_PRESETS: readonly DelimiterPreset[] = [
+  { label: "Comma ( , )", value: "," },
+  { label: "Tab ( \\t )", value: "\\t", aliases: ["\t"] },
+  { label: "Semicolon ( ; )", value: ";" },
+  { label: "Pipe ( \\| )", value: "\\|" },
+  { label: "Whitespace ( \\s+ )", value: "\\s+" },
+  { label: "New line ( \\n )", value: "\\n", aliases: ["\n"] },
+];
+
+export function delimiterPresets(mode: DelimiterMode): readonly 
DelimiterPreset[] {
+  return mode === "regex" ? REGEX_PRESETS : CHAR_PRESETS;
+}
+
+/** The preset a stored value is, or undefined when it is a custom value (or 
empty). */
+export function matchDelimiterPreset(value: unknown, mode: DelimiterMode): 
DelimiterPreset | undefined {
+  if (typeof value !== "string" || value.length === 0) {
+    return undefined;
+  }
+  return delimiterPresets(mode).find(preset => preset.value === value || 
preset.aliases?.includes(value));
+}
+
+// Escapes someone may type into a single-character box, meaning the character 
they name.
+const CHAR_ESCAPES: Readonly<Record<string, string>> = { "\\t": "\t", "\\\\": 
"\\" };
+
+/** The character a typed escape such as `\t` names, or the typed text 
unchanged. */
+export function unescapeCharDelimiter(typed: string): string {
+  return CHAR_ESCAPES[typed] ?? typed;
+}
+
+// Java syntax the browser can't judge. JavaScript throws on possessive 
quantifiers, atomic groups and
+// inline flags, and (without the `u` flag) misreads Java's escapes: `\p{L}` 
becomes the literal text
+// "p{L}", `\x{2C}` becomes "x{2C}", `\e`/`\a`/`\h` become letters, `\Q…\E` 
and the `\A`/`\z` anchors
+// too. An escape only counts when an odd number of backslashes precedes it, 
so `\\p` (a literal
+// backslash, then p) is still checked.
+const JAVA_ONLY_SYNTAX = 
/[*+?}]\+|\(\?>|\(\?[a-zA-Z-]+[):]|(?<!\\)(?:\\\\)*\\(?:[pPxN]\{|[QhHvVzAZGRXea0])/;
+
+// Line terminators, which `.` leaves out. Mirrors 
UnnestStringOpDesc.LineBreaks.
+const LINE_BREAKS = [0x0a, 0x0d, 0x85, 0x2028, 0x2029];
+
+// Line breaks are tested separately; lone surrogate halves are not characters 
on their own.
+function isLineBreakOrSurrogate(code: number): boolean {
+  return LINE_BREAKS.includes(code) || (code >= 0xd800 && code <= 0xdfff);
+}
+
+/**
+ * Whether the pattern matches every character of the Basic Multilingual Plane 
other than line breaks,
+ * each tested on its own. This is exact rather than a sample, and cheap: a 
pattern that leaves any
+ * character behind stops at the first one it misses, and only a real 
match-everything pattern tests
+ * all ~63,000. Mirrored by UnnestStringOpDesc.matchesEveryCharacter.
+ */
+function matchesEveryCharacter(pattern: RegExp): boolean {
+  for (let code = 0; code <= 0xffff; code++) {
+    if (!isLineBreakOrSurrogate(code) && 
!pattern.test(String.fromCharCode(code))) {
+      return false;
+    }
+  }
+  return true;
+}
+
+/**
+ * Why a delimiter cannot be used, or undefined when it can. Emptiness is left 
to `required` and to
+ * the operator's own default; a char delimiter's length is left to the 
schema's `maxLength`.
+ *
+ * The pattern is checked with the browser's regex engine, while the operator 
runs it with Java's. A
+ * pattern using Java-specific syntax (see JAVA_ONLY_SYNTAX) is not checked 
here at all, since the
+ * browser would judge a different pattern. The operator makes the same checks 
with Java semantics when
+ * the workflow compiles (UnnestStringOpDesc.validateDelimiter), so nothing is 
left unchecked.
+ */
+export function delimiterError(value: unknown, mode: DelimiterMode): string | 
undefined {
+  if (mode !== "regex" || typeof value !== "string" || value.length === 0) {
+    return undefined;
+  }
+  if (JAVA_ONLY_SYNTAX.test(value)) {
+    return undefined;
+  }
+  let pattern: RegExp;
+  try {
+    pattern = new RegExp(value);
+  } catch (e) {
+    return e instanceof Error ? e.message : "Invalid regular expression";
+  }
+  if (pattern.test("")) {
+    return "This pattern matches an empty string, so it would split between 
every character";
+  }
+  if (matchesEveryCharacter(pattern)) {
+    const literal = value.length === 1 ? ` To split on a literal "${value}", 
use \\${value}` : "";
+    // A value's line breaks survive a pattern that leaves them out, so say 
what is left.
+    if (LINE_BREAKS.every(code => pattern.test(String.fromCharCode(code)))) {
+      return `This pattern matches every character, so nothing would be 
left.${literal}`;
+    }
+    return `This pattern matches every character except line breaks, so only 
line breaks would be left.${literal}`;
+  }
+  return undefined;
+}
diff --git 
a/frontend/src/app/common/formly/delimiter/delimiter.rendered.spec.ts 
b/frontend/src/app/common/formly/delimiter/delimiter.rendered.spec.ts
new file mode 100644
index 0000000000..7e162c0418
--- /dev/null
+++ b/frontend/src/app/common/formly/delimiter/delimiter.rendered.spec.ts
@@ -0,0 +1,209 @@
+/**
+ * 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 { Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormGroup, ReactiveFormsModule } from "@angular/forms";
+import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core";
+import { FormlyJsonschema } from "@ngx-formly/core/json-schema";
+import { FormlyNgZorroAntdModule } from "@ngx-formly/ng-zorro-antd";
+import { TEXERA_FORMLY_CONFIG } from "../formly-config";
+import { CUSTOM_DELIMITER } from "./delimiter-presets";
+import { DelimiterTypeComponent } from "./delimiter.type";
+
+// The property schemas the backend generates (UIWidget.UIWidgetCharDelimiter 
/ UIWidgetRegexDelimiter),
+// so this renders the widget the way the operator panel does: schema -> 
formly-jsonschema -> Texera config.
+const CSV_SCHEMA = {
+  type: "object",
+  properties: {
+    customDelimiter: {
+      type: "string",
+      title: "Delimiter",
+      default: ",",
+      maxLength: 1,
+      widget: { formlyConfig: { type: "delimiter", props: { delimiterMode: 
"char" } } },
+    },
+  },
+};
+
+const UNNEST_SCHEMA = {
+  type: "object",
+  required: ["Delimiter"],
+  properties: {
+    Delimiter: {
+      type: "string",
+      default: ",",
+      widget: { formlyConfig: { type: "delimiter", props: { delimiterMode: 
"regex" } } },
+    },
+  },
+};
+
+@Component({
+  template:
+    "<form [formGroup]='form'><formly-form [form]='form' [fields]='fields' 
[model]='model'></formly-form></form>",
+  imports: [ReactiveFormsModule, FormlyModule],
+})
+class HostComponent {
+  form = new FormGroup({});
+  fields: FormlyFieldConfig[] = [];
+  model: Record<string, unknown> = {};
+}
+
+describe("delimiter widget rendered from the operator schema", () => {
+  let fixture: ComponentFixture<HostComponent>;
+  let host: HostComponent;
+
+  async function render(schema: object, model: Record<string, unknown>): 
Promise<void> {
+    // No validation overrides: the panel only forces errors visible on a 
field with no validators, and
+    // formly-jsonschema gives every field one, so whether an error shows is 
up to the widget itself.
+    host.fields = [TestBed.inject(FormlyJsonschema).toFieldConfig(schema as 
never)];
+    host.model = model;
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+  }
+
+  const widget = (): DelimiterTypeComponent =>
+    fixture.debugElement.query(el => el.componentInstance instanceof 
DelimiterTypeComponent).componentInstance;
+  const customBox = (): HTMLInputElement | null => 
fixture.nativeElement.querySelector("input.delimiter-custom");
+  const errorText = (): string => 
(fixture.nativeElement.querySelector("nz-form-control")?.textContent ?? 
"").trim();
+
+  async function typeCustom(text: string): Promise<void> {
+    widget().onSelect(CUSTOM_DELIMITER);
+    fixture.detectChanges();
+    const box = customBox()!;
+    box.value = text;
+    box.dispatchEvent(new Event("input"));
+    fixture.detectChanges();
+    await fixture.whenStable();
+    fixture.detectChanges();
+  }
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [HostComponent, FormlyModule.forRoot(TEXERA_FORMLY_CONFIG), 
FormlyNgZorroAntdModule],
+    }).compileComponents();
+    fixture = TestBed.createComponent(HostComponent);
+    host = fixture.componentInstance;
+  });
+
+  it("renders a CSV scan's delimiter as the picker, not a text box", async () 
=> {
+    await render(CSV_SCHEMA, { customDelimiter: "\t" });
+
+    expect(widget().mode).toBe("char");
+    expect(widget().selected).toBe("\t");
+    expect(customBox()).toBeNull();
+  });
+
+  it("writes a picked preset into the operator's properties", async () => {
+    await render(CSV_SCHEMA, { customDelimiter: "," });
+
+    widget().onSelect("|");
+    fixture.detectChanges();
+
+    expect(host.form.value).toEqual({ customDelimiter: "|" });
+  });
+
+  it("flags a stored multi-character CSV delimiter with the schema's length 
message", async () => {
+    await render(CSV_SCHEMA, { customDelimiter: ",;" });
+
+    expect(host.form.valid).toBe(false);
+    expect(errorText()).toContain("should NOT be longer than 1 characters");
+  });
+
+  it("renders Unnest String's delimiter as the regex picker", async () => {
+    await render(UNNEST_SCHEMA, { Delimiter: "\\s+" });
+
+    expect(widget().mode).toBe("regex");
+    expect(widget().selected).toBe("\\s+");
+  });
+
+  it("shows why a custom regex does not compile, under the field", async () => 
{
+    await render(UNNEST_SCHEMA, { Delimiter: "," });
+
+    await typeCustom("[a-");
+
+    expect(host.form.valid).toBe(false);
+    expect(errorText()).toContain("Invalid regular expression");
+  });
+
+  it("warns under the field that a bare pipe would split every character", 
async () => {
+    await render(UNNEST_SCHEMA, { Delimiter: "," });
+
+    await typeCustom("|");
+
+    expect(host.form.valid).toBe(false);
+    expect(errorText()).toContain("matches an empty string");
+  });
+
+  it("warns under the field that an unescaped dot would leave nothing, and 
offers the escape", async () => {
+    await render(UNNEST_SCHEMA, { Delimiter: "," });
+
+    await typeCustom(".");
+
+    expect(host.form.valid).toBe(false);
+    expect(errorText()).toContain("only line breaks would be left");
+    expect(errorText()).toContain("\\.");
+  });
+
+  it("does not show a false error for Java-only syntax the operator accepts", 
async () => {
+    await render(UNNEST_SCHEMA, { Delimiter: "," });
+
+    await typeCustom(",++");
+
+    expect(host.form.valid).toBe(true);
+  });
+
+  it("lets \\t be typed despite the schema's one-character bound", async () => 
{
+    await render(CSV_SCHEMA, { customDelimiter: "," });
+    await typeCustom("#");
+
+    expect(customBox()!.hasAttribute("maxlength")).toBe(false);
+  });
+
+  it("stores a typed \\t in a CSV scan as a real tab", async () => {
+    await render(CSV_SCHEMA, { customDelimiter: "," });
+
+    await typeCustom("\\t");
+
+    expect(host.form.value).toEqual({ customDelimiter: "\t" });
+    expect(host.form.valid).toBe(true);
+  });
+
+  it("clears the error once the pattern is fixed", async () => {
+    await render(UNNEST_SCHEMA, { Delimiter: "," });
+    await typeCustom("(");
+    expect(host.form.valid).toBe(false);
+
+    await typeCustom("\\s*,\\s*");
+
+    expect(host.form.valid).toBe(true);
+    expect(host.form.value).toEqual({ Delimiter: "\\s*,\\s*" });
+    expect(errorText()).not.toContain("Invalid");
+  });
+
+  it("marks an emptied custom regex as required", async () => {
+    await render(UNNEST_SCHEMA, { Delimiter: "," });
+
+    await typeCustom("");
+
+    expect(host.form.valid).toBe(false);
+    expect(errorText()).toContain("This field is required");
+  });
+});
diff --git a/frontend/src/app/common/formly/delimiter/delimiter.type.html 
b/frontend/src/app/common/formly/delimiter/delimiter.type.html
new file mode 100644
index 0000000000..d20b8f74f7
--- /dev/null
+++ b/frontend/src/app/common/formly/delimiter/delimiter.type.html
@@ -0,0 +1,47 @@
+<!--
+ 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.
+-->
+
+<div class="delimiter-input">
+  <nz-select
+    class="delimiter-select"
+    nzPlaceHolder="Select a delimiter"
+    [attr.aria-label]="fieldLabel"
+    [ngModel]="selected"
+    (ngModelChange)="onSelect($event)"
+    [nzDisabled]="formControl.disabled">
+    <nz-option
+      *ngFor="let preset of presets"
+      [nzValue]="preset.value"
+      [nzLabel]="preset.label"></nz-option>
+    <nz-option
+      [nzValue]="CUSTOM"
+      [nzLabel]="mode === 'regex' ? 'Custom regex…' : 'Custom…'"></nz-option>
+  </nz-select>
+  <input
+    *ngIf="selected === CUSTOM"
+    nz-input
+    class="delimiter-custom"
+    [value]="formControl.value ?? ''"
+    (input)="onCustomInput($event)"
+    [disabled]="formControl.disabled"
+    [id]="id"
+    (blur)="formControl.markAsTouched()"
+    [attr.aria-label]="'Custom ' + fieldLabel"
+    [placeholder]="customPlaceholder" />
+</div>
diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/annotations/UIWidget.java
 b/frontend/src/app/common/formly/delimiter/delimiter.type.scss
similarity index 57%
copy from 
common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/annotations/UIWidget.java
copy to frontend/src/app/common/formly/delimiter/delimiter.type.scss
index 04bc6a05e6..c999abcce2 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/annotations/UIWidget.java
+++ b/frontend/src/app/common/formly/delimiter/delimiter.type.scss
@@ -1,4 +1,4 @@
-/*
+/**
  * 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
@@ -17,12 +17,13 @@
  * under the License.
  */
 
-package org.apache.texera.amber.operator.metadata.annotations;
-
-public class UIWidget {
-
-    public static final String UIWidgetTextArea = "{ \"widget\": {\n          
\"formlyConfig\": {\n            \"type\": \"textarea\",\n            
\"templateOptions\": {\n              \"autosize\": true,\n              
\"autosizeMinRows\": 3\n            }\n          }\n        }\n      }";
-
-    public static final String UIWidgetPassword = "{ \"widget\": {\n          
\"formlyConfig\": {\n            \"templateOptions\": {\n              
\"type\": \"password\"\n            }\n          }\n        }\n      }";
+.delimiter-input {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  width: 100%;
+}
 
+.delimiter-select {
+  width: 100%;
 }
diff --git a/frontend/src/app/common/formly/delimiter/delimiter.type.spec.ts 
b/frontend/src/app/common/formly/delimiter/delimiter.type.spec.ts
new file mode 100644
index 0000000000..24101dfabe
--- /dev/null
+++ b/frontend/src/app/common/formly/delimiter/delimiter.type.spec.ts
@@ -0,0 +1,227 @@
+/**
+ * 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 { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormControl } from "@angular/forms";
+import { FieldTypeConfig } from "@ngx-formly/core";
+import { CUSTOM_DELIMITER, DelimiterMode, delimiterPresets } from 
"./delimiter-presets";
+import { DelimiterTypeComponent } from "./delimiter.type";
+
+describe("DelimiterTypeComponent", () => {
+  let component: DelimiterTypeComponent;
+  let fixture: ComponentFixture<DelimiterTypeComponent>;
+
+  function setField(initialValue: unknown, delimiterMode: DelimiterMode = 
"char"): FormControl {
+    const formControl = new FormControl(initialValue);
+    component.field = { formControl, props: { delimiterMode } } as unknown as 
FieldTypeConfig;
+    return formControl;
+  }
+
+  const customBox = (): HTMLInputElement | null => 
fixture.nativeElement.querySelector("input.delimiter-custom");
+  const shownSelection = (): string =>
+    
(fixture.nativeElement.querySelector(".ant-select-selection-item")?.textContent 
?? "").trim();
+
+  function typeInto(box: HTMLInputElement, text: string): void {
+    box.value = text;
+    box.dispatchEvent(new Event("input"));
+    fixture.detectChanges();
+  }
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({ imports: [DelimiterTypeComponent] 
}).compileComponents();
+    fixture = TestBed.createComponent(DelimiterTypeComponent);
+    component = fixture.componentInstance;
+  });
+
+  describe.each(["char", "regex"] as DelimiterMode[])("in %s mode", mode => {
+    it.each(delimiterPresets(mode).map(p => [p.label, p.value]))(
+      "stores %s as its value when picked, with no text box",
+      async (label, value) => {
+        const formControl = setField(",", mode);
+        fixture.detectChanges();
+
+        component.onSelect(value);
+        fixture.detectChanges();
+        await fixture.whenStable();
+        fixture.detectChanges();
+
+        expect(formControl.value).toBe(value);
+        expect(formControl.dirty).toBe(true);
+        expect(formControl.touched).toBe(true);
+        expect(component.selected).toBe(value);
+        expect(shownSelection()).toBe(label);
+        expect(customBox()).toBeNull();
+      }
+    );
+
+    it("opens an empty text box when Custom is picked from a preset", () => {
+      const formControl = setField(",", mode);
+
+      component.onSelect(CUSTOM_DELIMITER);
+      fixture.detectChanges();
+
+      expect(formControl.value).toBe("");
+      expect(component.selected).toBe(CUSTOM_DELIMITER);
+      expect(customBox()).not.toBeNull();
+    });
+
+    it("stays on Custom while the typed value happens to equal a preset", () 
=> {
+      const formControl = setField(",", mode);
+      component.onSelect(CUSTOM_DELIMITER);
+      fixture.detectChanges();
+
+      typeInto(customBox()!, ";");
+
+      expect(formControl.value).toBe(";");
+      expect(component.selected).toBe(CUSTOM_DELIMITER);
+      expect(customBox()).not.toBeNull();
+    });
+
+    it("stays on Custom after the custom value is cleared", () => {
+      const formControl = setField(",", mode);
+      component.onSelect(CUSTOM_DELIMITER);
+
+      formControl.setValue("");
+
+      expect(component.selected).toBe(CUSTOM_DELIMITER);
+    });
+
+    it("closes the text box when a preset is picked after Custom", () => {
+      const formControl = setField(",", mode);
+      component.onSelect(CUSTOM_DELIMITER);
+      fixture.detectChanges();
+
+      component.onSelect(";");
+      fixture.detectChanges();
+
+      expect(formControl.value).toBe(";");
+      expect(customBox()).toBeNull();
+    });
+
+    it("selects nothing while the property is unset", () => {
+      setField(null, mode);
+      expect(component.selected).toBeNull();
+      setField("", mode);
+      expect(component.selected).toBeNull();
+    });
+
+    it("disables the picker with the form, e.g. on a read-only workflow", () 
=> {
+      const formControl = setField(",", mode);
+      formControl.disable();
+      fixture.detectChanges();
+
+      
expect(fixture.nativeElement.querySelector("nz-select").classList).toContain("ant-select-disabled");
+    });
+  });
+
+  it("shows a custom char delimiter with a single-character placeholder", () 
=> {
+    setField("#");
+    fixture.detectChanges();
+
+    expect(customBox()!.value).toBe("#");
+    expect(customBox()!.placeholder).toBe("Any single character");
+  });
+
+  it("turns a typed \\t into a tab and shows it as the Tab preset", () => {
+    const formControl = setField(",");
+    component.onSelect(CUSTOM_DELIMITER);
+    fixture.detectChanges();
+
+    typeInto(customBox()!, "\\t");
+
+    expect(formControl.value).toBe("\t");
+    expect(component.selected).toBe("\t");
+    expect(customBox()).toBeNull();
+  });
+
+  it("lets a backslash be typed on its way to an escape, rather than capping 
it at one character", () => {
+    const formControl = setField(",");
+    component.onSelect(CUSTOM_DELIMITER);
+    fixture.detectChanges();
+
+    typeInto(customBox()!, "\\");
+    expect(formControl.value).toBe("\\");
+    expect(customBox()!.hasAttribute("maxlength")).toBe(false);
+  });
+
+  it("follows the value back to its preset when it changes from outside, e.g. 
undo or a co-editor", () => {
+    const formControl = setField(",");
+    fixture.detectChanges();
+    component.onSelect(CUSTOM_DELIMITER);
+    fixture.detectChanges();
+    typeInto(customBox()!, ";");
+
+    formControl.setValue(",");
+    fixture.detectChanges();
+    formControl.setValue(";");
+    fixture.detectChanges();
+
+    expect(component.selected).toBe(";");
+    expect(customBox()).toBeNull();
+  });
+
+  it("labels the picker and the text box for screen readers", () => {
+    component.field = {
+      formControl: new FormControl("#"),
+      props: { delimiterMode: "char", label: "Delimiter" },
+    } as unknown as FieldTypeConfig;
+    fixture.detectChanges();
+
+    
expect(fixture.nativeElement.querySelector("nz-select").getAttribute("aria-label")).toBe("Delimiter");
+    expect(customBox()!.getAttribute("aria-label")).toBe("Custom Delimiter");
+  });
+
+  it("lets a custom regex be any length, with an example in the placeholder", 
() => {
+    setField("[,;]\\s*", "regex");
+    fixture.detectChanges();
+
+    expect(component.selected).toBe(CUSTOM_DELIMITER);
+    expect(customBox()!.value).toBe("[,;]\\s*");
+    expect(customBox()!.hasAttribute("maxlength")).toBe(false);
+    expect(customBox()!.placeholder).toContain("Regular expression");
+  });
+
+  it("keeps a custom value when Custom is picked again", () => {
+    const formControl = setField("#");
+
+    component.onSelect(CUSTOM_DELIMITER);
+
+    expect(formControl.value).toBe("#");
+  });
+
+  it("shows a stored tab as the Tab preset in both modes", () => {
+    setField("\t", "char");
+    expect(component.selected).toBe("\t");
+    setField("\t", "regex");
+    expect(component.selected).toBe("\\t");
+  });
+
+  it("opens an older workflow's over-long char delimiter on Custom so it can 
be fixed", () => {
+    setField(",;");
+    fixture.detectChanges();
+
+    expect(component.selected).toBe(CUSTOM_DELIMITER);
+    expect(customBox()!.value).toBe(",;");
+  });
+
+  it("falls back to char mode when the schema names no mode", () => {
+    component.field = { formControl: new FormControl(","), props: {} } as 
unknown as FieldTypeConfig;
+    expect(component.mode).toBe("char");
+  });
+});
diff --git a/frontend/src/app/common/formly/delimiter/delimiter.type.ts 
b/frontend/src/app/common/formly/delimiter/delimiter.type.ts
new file mode 100644
index 0000000000..1a0898faac
--- /dev/null
+++ b/frontend/src/app/common/formly/delimiter/delimiter.type.ts
@@ -0,0 +1,135 @@
+/**
+ * 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 { Component, OnInit } from "@angular/core";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { NgFor, NgIf } from "@angular/common";
+import { FormsModule } from "@angular/forms";
+import { FieldType, FieldTypeConfig } from "@ngx-formly/core";
+import { NzSelectModule } from "ng-zorro-antd/select";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import {
+  CUSTOM_DELIMITER,
+  DelimiterMode,
+  DelimiterPreset,
+  delimiterPresets,
+  matchDelimiterPreset,
+  unescapeCharDelimiter,
+} from "./delimiter-presets";
+
+/**
+ * Delimiter picker: common delimiters are chosen by name, so nobody has to 
wonder whether a tab is
+ * typed as a tab key press or as `\t`; anything else is typed into a box that 
opens under "Custom".
+ * The stored value is unchanged from the plain text field this replaces -- 
the delimiter itself --
+ * so existing workflows load as they were. `props.delimiterMode` ("char" or 
"regex") picks the
+ * presets and how a custom value is checked (see delimiterError, registered 
with this type).
+ *
+ * The Custom box is bound by hand rather than through formlyAttributes, which 
would copy the schema's
+ * `maxLength: 1` onto it as a `maxlength` attribute and block the second key 
of a typed `\t`. The
+ * length is still enforced by the schema's validator.
+ */
+@UntilDestroy()
+@Component({
+  selector: "texera-delimiter-type",
+  templateUrl: "./delimiter.type.html",
+  styleUrls: ["./delimiter.type.scss"],
+  imports: [NgFor, NgIf, FormsModule, NzSelectModule, NzInputDirective],
+})
+export class DelimiterTypeComponent extends FieldType<FieldTypeConfig> 
implements OnInit {
+  readonly CUSTOM = CUSTOM_DELIMITER;
+
+  // The value last written from the Custom box, so typing a value that 
happens to equal a preset does
+  // not snap the select back to that preset mid-edit. A value that differs 
came from elsewhere (undo, a
+  // co-editor, the agent), and the select follows it. Undefined while Custom 
is not in use.
+  private customValue: unknown = undefined;
+
+  ngOnInit(): void {
+    // Any write this widget did not make itself ends the Custom edit, even 
when it lands back on the
+    // custom value (undo, then redo).
+    this.formControl.valueChanges.pipe(untilDestroyed(this)).subscribe(value 
=> {
+      if (!this.isCustomValue(value)) {
+        this.customValue = undefined;
+      }
+    });
+  }
+
+  // Formly stores a cleared optional field as undefined rather than "", so 
every empty value is the
+  // same empty custom value.
+  private isCustomValue(value: unknown): boolean {
+    if (this.customValue === undefined) {
+      return false;
+    }
+    const isEmpty = (v: unknown) => v === undefined || v === null || v === "";
+    return value === this.customValue || (isEmpty(value) && 
isEmpty(this.customValue));
+  }
+
+  get mode(): DelimiterMode {
+    return (this.props as { delimiterMode?: DelimiterMode } | 
undefined)?.delimiterMode === "regex" ? "regex" : "char";
+  }
+
+  get fieldLabel(): string {
+    return (this.props?.label as string | undefined) ?? "Delimiter";
+  }
+
+  get presets(): readonly DelimiterPreset[] {
+    return delimiterPresets(this.mode);
+  }
+
+  /** The select's value: the matching preset, "Custom", or nothing while the 
property is unset. */
+  get selected(): string | null {
+    const value = this.formControl?.value;
+    const inCustom = this.isCustomValue(value);
+    const preset = matchDelimiterPreset(value, this.mode);
+    if (!inCustom && preset) {
+      return preset.value;
+    }
+    if (inCustom || (typeof value === "string" && value.length > 0)) {
+      return CUSTOM_DELIMITER;
+    }
+    return null;
+  }
+
+  get customPlaceholder(): string {
+    return this.mode === "regex" ? "Regular expression, e.g. [,;]\\s*" : "Any 
single character";
+  }
+
+  onSelect(choice: string): void {
+    if (choice === CUSTOM_DELIMITER) {
+      // Start from what is there when it is already custom; a preset's value 
would only be in the way.
+      if (matchDelimiterPreset(this.formControl.value, this.mode)) {
+        this.formControl.setValue("");
+      }
+      this.customValue = this.formControl.value ?? "";
+    } else {
+      this.customValue = undefined;
+      this.formControl.setValue(choice);
+    }
+    this.formControl.markAsDirty();
+    this.formControl.markAsTouched();
+  }
+
+  onCustomInput(event: Event): void {
+    const typed = (event.target as HTMLInputElement).value;
+    const value = this.mode === "char" ? unescapeCharDelimiter(typed) : typed;
+    // A typed escape that names a preset (`\t`) is that preset, so the select 
shows it by name.
+    this.customValue = value !== typed && matchDelimiterPreset(value, 
this.mode) ? undefined : value;
+    this.formControl.setValue(value);
+    this.formControl.markAsDirty();
+  }
+}
diff --git a/frontend/src/app/common/formly/formly-config.spec.ts 
b/frontend/src/app/common/formly/formly-config.spec.ts
index 57e8932dca..1c7c1e4a31 100644
--- a/frontend/src/app/common/formly/formly-config.spec.ts
+++ b/frontend/src/app/common/formly/formly-config.spec.ts
@@ -17,9 +17,11 @@
  * under the License.
  */
 
+import { FormControl } from "@angular/forms";
 import { FormlyFieldConfig } from "@ngx-formly/core";
 import {
   constValidationMessage,
+  delimiterValidationMessage,
   exclusiveMaximumValidationMessage,
   exclusiveMinimumValidationMessage,
   maxItemsValidationMessage,
@@ -29,6 +31,7 @@ import {
   minlengthValidationMessage,
   minValidationMessage,
   multipleOfValidationMessage,
+  TEXERA_FORMLY_CONFIG,
 } from "./formly-config";
 
 // the `err` argument is unused by every message builder, so any value is fine
@@ -81,3 +84,21 @@ describe("formly validation messages", () => {
     expect(constValidationMessage(err, field({}))).toBe('should be equal to 
constant "undefined"');
   });
 });
+
+describe("delimiter type", () => {
+  const delimiterType = TEXERA_FORMLY_CONFIG.types.find(type => type.name === 
"delimiter")!;
+  const validator = (delimiterType.defaultOptions as any).validators.delimiter;
+  const delimiterField = (value: string, delimiterMode: string): 
FormlyFieldConfig =>
+    ({ props: { delimiterMode }, formControl: new FormControl(value) }) as 
unknown as FormlyFieldConfig;
+
+  it("rejects a regex delimiter that does not compile, and says why", () => {
+    const f = delimiterField("(", "regex");
+    expect(validator.expression(f.formControl, f)).toBe(false);
+    expect(delimiterValidationMessage(err, f)).toMatch(/Invalid regular 
expression/);
+  });
+
+  it("accepts the same text as a single-character delimiter", () => {
+    const f = delimiterField("(", "char");
+    expect(validator.expression(f.formControl, f)).toBe(true);
+  });
+});
diff --git a/frontend/src/app/common/formly/formly-config.ts 
b/frontend/src/app/common/formly/formly-config.ts
index 3d56e1f07c..d4aedb300b 100644
--- a/frontend/src/app/common/formly/formly-config.ts
+++ b/frontend/src/app/common/formly/formly-config.ts
@@ -22,6 +22,9 @@ import { ArrayTypeComponent } from "./array.type";
 import { ObjectTypeComponent } from "./object.type";
 import { MultiSchemaTypeComponent } from "./multischema.type";
 import { FormlyFieldConfig } from "@ngx-formly/core";
+import { AbstractControl } from "@angular/forms";
+import { DelimiterTypeComponent } from "./delimiter/delimiter.type";
+import { DelimiterMode, delimiterError } from "./delimiter/delimiter-presets";
 import { CodeareaCustomTemplateComponent } from 
"../../workspace/component/codearea-custom-template/codearea-custom-template.component";
 import { PresetWrapperComponent } from 
"./preset-wrapper/preset-wrapper.component";
 import { DatasetFileSelectorComponent } from 
"../../workspace/component/dataset-file-selector/dataset-file-selector.component";
@@ -94,6 +97,23 @@ export const TEXERA_FORMLY_CONFIG = {
     { name: "ui-udf-parameters", component: UiUdfParametersComponent, 
wrappers: ["form-field"] },
     { name: "constrainedvalue", component: ConstrainedValueComponent, 
wrappers: ["form-field"] },
     { name: "resourcevalue", component: ResourceValueSelectorComponent },
+    {
+      name: "delimiter",
+      component: DelimiterTypeComponent,
+      wrappers: ["form-field"],
+      defaultOptions: {
+        // Shown without waiting for a touch: a delimiter loaded from a saved 
workflow can already be
+        // invalid (an old multi-character one), and nobody touches a field 
they do not know is wrong.
+        validation: { show: true },
+        validators: {
+          delimiter: {
+            expression: (control: AbstractControl, field: FormlyFieldConfig) =>
+              delimiterError(control.value, delimiterModeOf(field)) === 
undefined,
+            message: delimiterValidationMessage,
+          },
+        },
+      },
+    },
   ],
   wrappers: [
     { name: "preset-wrapper", component: PresetWrapperComponent },
@@ -139,6 +159,14 @@ export function exclusiveMaximumValidationMessage(err: 
any, field: FormlyFieldCo
   return `should be < ${field.props?.exclusiveMaximum}`;
 }
 
+function delimiterModeOf(field: FormlyFieldConfig): DelimiterMode {
+  return field.props?.["delimiterMode"] === "regex" ? "regex" : "char";
+}
+
+export function delimiterValidationMessage(err: any, field: FormlyFieldConfig) 
{
+  return delimiterError(field.formControl?.value, delimiterModeOf(field)) ?? 
"";
+}
+
 export function constValidationMessage(err: any, field: FormlyFieldConfig) {
   return `should be equal to constant "${field.props?.const}"`;
 }

Reply via email to