You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@texera.apache.org by gi...@apache.org on 2026/09/01 00:43:53 UTC

(texera) branch gh-readonly-queue/main/pr-8125-11e3b969a70063d17bfd670b1da85dba100e4339 created (now b28faee36f)

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

github-merge-queue[bot] pushed a change to branch gh-readonly-queue/main/pr-8125-11e3b969a70063d17bfd670b1da85dba100e4339
in repository https://gitbox.apache.org/repos/asf/texera.git


      at b28faee36f feat(workflow): persist is_form_view and add Form View toggle endpoints (#8125)

This branch includes the following new commits:

     new b28faee36f feat(workflow): persist is_form_view and add Form View toggle endpoints (#8125)

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(texera) 01/01: feat(workflow): persist is_form_view and add Form View toggle endpoints (#8125)

Posted by gi...@apache.org.
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-8125-11e3b969a70063d17bfd670b1da85dba100e4339
in repository https://gitbox.apache.org/repos/asf/texera.git

commit b28faee36ff27bca7d47acb13de27c99828f6981
Author: yangzhang75 <ya...@uci.edu>
AuthorDate: Tue Sep 1 00:43:31 2026 +0000

    feat(workflow): persist is_form_view and add Form View toggle endpoints (#8125)
    
    ### What changes were proposed in this PR?
    
    Let the backend remember, per workflow, which view it opens in by
    default (canvas or form), and expose setting it. Backend + DB only; no
    UI or form logic. The Form View is available for every workflow, so this
    column is a default-entry preference, not a capability gate.
    
    - **Schema**: new column `workflow.default_view VARCHAR NOT NULL DEFAULT
    'CANVAS'` (values `CANVAS`, `FORM`) in `sql/updates/44.sql`,
    `changelog.xml`, `texera_ddl.sql`.
    - **Write**: `PUT /set-default-view/{wid}` with a JSON body `{"view":
    "CANVAS" | "FORM"}`, guarded by write access and validated (any other
    value is a 400). It updates only that column, never rewriting content or
    bumping the modified time. A plain save preserves the stored value; a
    duplicate and a hub-clone both inherit it.
    - **Read**: `UnifiedResourceSchema` and `WorkflowSearchQueryBuilder`
    include the value in listing/search;
    `WorkflowVersionResource.cloneVersion` carries the source workflow's
    current value onto the clone; `retrieveWorkflow` /
    `retrievePublicWorkflow` report it.
    
    Naming and migration notes: the value is a `default_view` enum (canvas
    or form) rather than an on/off flag, and the Form View's actual
    definition still lives in `content.formBinding`; only this preference is
    denormalized into a column. The migration is `44.sql` because `43.sql`
    is reserved for a sibling PR and `42.sql` was the latest on main.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8014. Part of the Form View parent issue #8011; builds on the
    feature flag from #8013.
    
    ### How was this PR tested?
    
    `WorkflowResourceSpec` covers the set-default-view endpoint: the
    form/canvas switch, rejection without write access, rejection of an
    invalid view value, survival across a plain save, inheritance by both
    `duplicateWorkflow` and `cloneWorkflow`, reporting by both retrieve
    paths, and `formBinding` preserved when switching back to canvas.
    Together with the version-clone, listing/search and unified-schema
    specs, the four affected specs run 127 green. Compiled and run against
    an ephemeral Postgres so jOOQ regenerated the `default_view` column.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Co-authored with Claude Code.
    
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 .../resource/dashboard/UnifiedResourceSchema.scala |  10 +-
 .../dashboard/WorkflowSearchQueryBuilder.scala     |  14 +-
 .../web/resource/dashboard/hub/HubResource.scala   |   3 +-
 .../dashboard/user/workflow/WorkflowResource.scala |  96 +++++++--
 .../user/workflow/WorkflowVersionResource.scala    |   4 +-
 .../dashboard/UnifiedResourceSchemaSpec.scala      |  31 +--
 .../dashboard/WorkflowSearchQueryBuilderSpec.scala |  25 ++-
 .../dashboard/file/WorkflowResourceSpec.scala      | 220 ++++++++++++++++++++-
 .../workflow/WorkflowVersionResourceSpec.scala     |  35 +++-
 sql/changelog.xml                                  |   5 +
 sql/texera_ddl.sql                                 |   7 +-
 sql/updates/44.sql                                 |  39 ++++
 12 files changed, 445 insertions(+), 44 deletions(-)

diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
index cfe3f381a4..7ffa2982d0 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala
@@ -20,7 +20,7 @@
 package org.apache.texera.web.resource.dashboard
 
 import org.apache.texera.dao.SqlServer
-import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, PrivilegeEnum}
 import org.apache.texera.web.resource.dashboard.UnifiedResourceSchema.context
 import org.jooq.impl.DSL
 import org.jooq.{Field, Record}
@@ -79,7 +79,10 @@ object UnifiedResourceSchema {
         DSL.cast(null, classOf[java.lang.Boolean]),
       versionedResourceUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]),
       versionedResourceCoverImage: Field[String] = DSL.cast(null, classOf[String]),
-      workflowCoverImage: Field[String] = DSL.cast(null, classOf[String])
+      workflowCoverImage: Field[String] = DSL.cast(null, classOf[String]),
+      // Workflow-only: which view the workflow opens in by default, so the listing can
+      // mark the row and route it accordingly.
+      workflowDefaultView: Field[DefaultViewEnum] = DSL.cast(null, classOf[DefaultViewEnum])
   ): UnifiedResourceSchema = {
     new UnifiedResourceSchema(
       Seq(
@@ -110,7 +113,8 @@ object UnifiedResourceSchema {
           .as("user_versioned_resource_access"),
         versionedResourceCoverImage -> versionedResourceCoverImage
           .as("versioned_resource_cover_image"),
-        workflowCoverImage -> workflowCoverImage.as("workflow_cover_image")
+        workflowCoverImage -> workflowCoverImage.as("workflow_cover_image"),
+        workflowDefaultView -> workflowDefaultView.as("workflow_default_view")
       )
     )
   }
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala
index b44ccaf30c..d668e73f12 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilder.scala
@@ -29,7 +29,7 @@ import org.jooq.impl.DSL.groupConcatDistinct
 import org.jooq.{Condition, GroupField, Record, TableLike}
 
 import scala.jdk.CollectionConverters.CollectionHasAsScala
-import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, PrivilegeEnum}
 
 object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
 
@@ -54,7 +54,8 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
       ownerId = WORKFLOW_OF_USER.UID,
       userName = USER.NAME,
       projectsOfWorkflow = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID),
-      workflowCoverImage = DSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image")
+      workflowCoverImage = DSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image"),
+      workflowDefaultView = WORKFLOW.DEFAULT_VIEW.as("workflow_default_view")
     )
   }
 
@@ -156,8 +157,13 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
       Option(record.get(WORKFLOW_USER_ACCESS.PRIVILEGE, classOf[PrivilegeEnum]))
         .map(_.toString)
         .getOrElse(PrivilegeEnum.NONE.toString),
-      record.into(USER).getName,
-      record.into(WORKFLOW).into(classOf[Workflow]),
+      record.into(USER).getName, {
+        // The select lists specific columns, so the POJO built from the record does not carry
+        // this one. Without it the listing forgets the default-view preference on every refresh.
+        val w = record.into(WORKFLOW).into(classOf[Workflow])
+        w.setDefaultView(record.get("workflow_default_view", classOf[DefaultViewEnum]))
+        w
+      },
       if (record.get(pidField) == null) {
         List[Integer]()
       } else {
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
index 0dd3cfc1b6..424eb9af85 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/hub/HubResource.scala
@@ -267,7 +267,8 @@ object HubResource {
         WORKFLOW.LAST_MODIFIED_TIME,
         WORKFLOW_USER_ACCESS.PRIVILEGE,
         WORKFLOW_OF_USER.UID,
-        USER.NAME
+        USER.NAME,
+        WORKFLOW.DEFAULT_VIEW
       )
       .fetch()
 
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
index 315418516d..d2c7d62731 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResource.scala
@@ -28,7 +28,7 @@ import org.apache.texera.amber.core.virtualidentity.ExecutionIdentity
 import org.apache.texera.auth.SessionUser
 import org.apache.texera.dao.SqlServer
 import org.apache.texera.dao.jooq.generated.Tables._
-import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, PrivilegeEnum}
 import org.apache.texera.dao.jooq.generated.tables.daos.{
   WorkflowDao,
   WorkflowOfProjectDao,
@@ -43,7 +43,7 @@ import org.apache.texera.web.resource.dashboard.hub.HubResource.recordCloneActio
 import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowAccessResource.hasReadAccess
 import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource._
 import org.jooq.impl.DSL.{groupConcatDistinct, noCondition, max}
-import org.jooq.{Condition, DSLContext, Record10, Result, SelectOnConditionStep}
+import org.jooq.{Condition, DSLContext, Record11, Result, SelectOnConditionStep}
 
 import java.sql.Timestamp
 import java.util
@@ -84,6 +84,9 @@ object WorkflowResource {
   /** JSON body/response for a workflow's cover image data URL. */
   case class CoverImageRequest(image: String)
 
+  /** JSON body for setting which view a workflow opens in by default (CANVAS or FORM). */
+  case class DefaultViewRequest(view: String)
+
   def getWorkflowName(wid: Integer): String = {
     val workflow = workflowDao.fetchOneByWid(wid)
     if (workflow == null) {
@@ -138,7 +141,9 @@ object WorkflowResource {
       creationTime: Timestamp,
       lastModifiedTime: Timestamp,
       isPublished: Boolean,
-      readonly: Boolean
+      readonly: Boolean,
+      // Which view this workflow opens in by default (CANVAS or FORM); both load through this endpoint.
+      defaultView: DefaultViewEnum
   )
 
   case class WorkflowIDs(wids: List[Integer], pid: Option[Integer])
@@ -193,7 +198,7 @@ object WorkflowResource {
     }
   }
 
-  def baseWorkflowSelect(): SelectOnConditionStep[Record10[
+  def baseWorkflowSelect(): SelectOnConditionStep[Record11[
     Integer,
     String,
     String,
@@ -203,7 +208,8 @@ object WorkflowResource {
     Integer,
     String,
     String,
-    String
+    String,
+    DefaultViewEnum
   ]] = {
     context
       .select(
@@ -216,7 +222,8 @@ object WorkflowResource {
         WORKFLOW_OF_USER.UID,
         USER.NAME,
         groupConcatDistinct(WORKFLOW_OF_PROJECT.PID).as("projects"),
-        max(WORKFLOW_COVER_IMAGE.IMAGE).as("cover_image")
+        max(WORKFLOW_COVER_IMAGE.IMAGE).as("cover_image"),
+        WORKFLOW.DEFAULT_VIEW
       )
       .from(WORKFLOW)
       .leftJoin(WORKFLOW_USER_ACCESS)
@@ -232,7 +239,7 @@ object WorkflowResource {
   }
 
   def mapWorkflowEntries(
-      workflowEntries: Result[Record10[
+      workflowEntries: Result[Record11[
         Integer,
         String,
         String,
@@ -242,7 +249,8 @@ object WorkflowResource {
         Integer,
         String,
         String,
-        String
+        String,
+        DefaultViewEnum
       ]],
       uid: Integer
   ): List[DashboardWorkflow] = {
@@ -386,7 +394,8 @@ class WorkflowResource extends LazyLogging {
         WORKFLOW.LAST_MODIFIED_TIME,
         WORKFLOW_USER_ACCESS.PRIVILEGE,
         WORKFLOW_OF_USER.UID,
-        USER.NAME
+        USER.NAME,
+        WORKFLOW.DEFAULT_VIEW
       )
       .fetch()
     mapWorkflowEntries(workflowEntries, user.getUid)
@@ -417,7 +426,8 @@ class WorkflowResource extends LazyLogging {
         workflow.getCreationTime,
         workflow.getLastModifiedTime,
         workflow.getIsPublic,
-        !WorkflowAccessResource.hasWriteAccess(wid, user.getUid)
+        !WorkflowAccessResource.hasWriteAccess(wid, user.getUid),
+        workflow.getDefaultView
       )
     } else {
       throw new ForbiddenException("No sufficient access privilege.")
@@ -439,9 +449,10 @@ class WorkflowResource extends LazyLogging {
   @Path("/persist")
   def persistWorkflow(workflow: Workflow, @Auth sessionUser: SessionUser): Workflow = {
     val user = sessionUser.getUser
+
     if (workflowOfUserExists(workflow.getWid, user.getUid)) {
       WorkflowVersionResource.insertVersion(workflow, insertingNewWorkflow = false)
-      workflowDao.update(workflow)
+      saveWorkflowFields(workflow)
     } else {
       if (!WorkflowAccessResource.hasReadAccess(workflow.getWid, user.getUid)) {
         // Check if this workflow exists in the database
@@ -458,7 +469,7 @@ class WorkflowResource extends LazyLogging {
       } else if (WorkflowAccessResource.hasWriteAccess(workflow.getWid, user.getUid)) {
         WorkflowVersionResource.insertVersion(workflow, insertingNewWorkflow = false)
         // not owner but has write access
-        workflowDao.update(workflow)
+        saveWorkflowFields(workflow)
       } else {
         // not owner and no write access -> rejected
         throw new ForbiddenException("No sufficient access privilege.")
@@ -469,6 +480,23 @@ class WorkflowResource extends LazyLogging {
     workflowDao.fetchOneByWid(wid)
   }
 
+  /**
+    * Persists a plain save by updating only the fields the client sends
+    * (name/description/content/is_public). It deliberately leaves `default_view` untouched --
+    * that column is owned by /set-default-view alone -- so a save can never clobber a
+    * concurrent change. Timestamps are likewise not rewritten here.
+    */
+  private def saveWorkflowFields(workflow: Workflow): Unit = {
+    context
+      .update(WORKFLOW)
+      .set(WORKFLOW.NAME, workflow.getName)
+      .set(WORKFLOW.DESCRIPTION, workflow.getDescription)
+      .set(WORKFLOW.CONTENT, workflow.getContent)
+      .set(WORKFLOW.IS_PUBLIC, workflow.getIsPublic)
+      .where(WORKFLOW.WID.eq(workflow.getWid))
+      .execute()
+  }
+
   /**
     * This method duplicates the target workflow, the new workflow name is appended with `_copy`
     *
@@ -507,7 +535,9 @@ class WorkflowResource extends LazyLogging {
               assignNewOperatorIds(oldWorkflow.getContent),
               null,
               null,
-              false
+              false,
+              // the default view is part of the workflow, so a copy keeps it
+              oldWorkflow.getDefaultView
             ),
             sessionUser
           )
@@ -557,7 +587,9 @@ class WorkflowResource extends LazyLogging {
         assignNewOperatorIds(oldWorkflow.getContent),
         null,
         null,
-        false
+        false,
+        // a biologist's path is hub -> clone -> use, so the clone must stay usable
+        oldWorkflow.getDefaultView
       ),
       sessionUser
     )
@@ -726,6 +758,39 @@ class WorkflowResource extends LazyLogging {
     workflowDao.update(workflow)
   }
 
+  /**
+    * Set which view a workflow opens in by default (CANVAS or FORM). Only this preference lives
+    * in a column; the form's definition travels in workflow.content under `formBinding`, so
+    * switching the default never touches it.
+    */
+  @PUT
+  @Consumes(Array(MediaType.APPLICATION_JSON))
+  @RolesAllowed(Array("REGULAR", "ADMIN"))
+  @Path("/set-default-view/{wid}")
+  def setDefaultView(
+      @PathParam("wid") wid: Integer,
+      request: DefaultViewRequest,
+      @Auth user: SessionUser
+  ): Unit = {
+    if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) {
+      throw new ForbiddenException(s"You do not have permission to modify workflow $wid")
+    }
+    // lookupLiteral returns null for an unknown literal and for a null/missing body value,
+    // so both an out-of-range string and an empty request map to a 400 rather than a 500.
+    val view = DefaultViewEnum.lookupLiteral(request.view)
+    if (view == null) {
+      throw new BadRequestException(s"default_view must be CANVAS or FORM, got: ${request.view}")
+    }
+    // Update only this column. The preference is deliberately independent of content, so a change
+    // must not rewrite the whole row -- doing so would touch content (and could clobber a
+    // concurrent save) and bump the last-modified time for a mere preference change.
+    context
+      .update(WORKFLOW)
+      .set(WORKFLOW.DEFAULT_VIEW, view)
+      .where(WORKFLOW.WID.eq(wid))
+      .execute()
+  }
+
   /** Returns the workflow's cover image; 404 if none set. */
   @GET
   @RolesAllowed(Array("REGULAR", "ADMIN"))
@@ -848,7 +913,8 @@ class WorkflowResource extends LazyLogging {
       workflow.getCreationTime,
       workflow.getLastModifiedTime,
       workflow.getIsPublic,
-      readonly = true
+      readonly = true,
+      defaultView = workflow.getDefaultView
     )
   }
 
diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala
index e0664b7c1d..bae03efbd7 100644
--- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala
+++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResource.scala
@@ -435,7 +435,9 @@ class WorkflowVersionResource {
             assignNewOperatorIds(workflowVersion.getContent),
             null,
             null,
-            false
+            false,
+            // carry the workflow's current default-view preference onto the clone
+            workflowVersion.getDefaultView
           ),
           sessionUser
         )
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
index 52890b4bf0..9334fc5b0d 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchemaSpec.scala
@@ -79,7 +79,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
 
   // Sentinels for the three slots that have no convenient distinct table
   // column of the right type; every other slot uses a real generated column so
-  // that all 24 originals render differently from one another.
+  // that all 25 originals render differently from one another.
   private val sentinelResourceType: Field[String] = JDSL.inline("s-resource-type")
   private val sentinelProjects: Field[String] = JDSL.inline("s-projects")
   private val sentinelStoragePath: Field[String] = JDSL.inline("s-storage-path")
@@ -108,7 +108,8 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
     isVersionedResourceDownloadable = DATASET.IS_DOWNLOADABLE,
     versionedResourceUserAccess = DATASET_USER_ACCESS.PRIVILEGE,
     versionedResourceCoverImage = DATASET.COVER_IMAGE,
-    workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE
+    workflowCoverImage = WORKFLOW_COVER_IMAGE.IMAGE,
+    workflowDefaultView = WORKFLOW.DEFAULT_VIEW
   )
 
   // Expected projection, in order: alias -> the original it must be built from.
@@ -136,13 +137,14 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
     "is_versioned_resource_downloadable" -> DATASET.IS_DOWNLOADABLE,
     "user_versioned_resource_access" -> DATASET_USER_ACCESS.PRIVILEGE,
     "versioned_resource_cover_image" -> DATASET.COVER_IMAGE,
-    "workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE
+    "workflow_cover_image" -> WORKFLOW_COVER_IMAGE.IMAGE,
+    "workflow_default_view" -> WORKFLOW.DEFAULT_VIEW
   )
 
   // -- apply(): the projection ------------------------------------------------
 
-  "apply" should "expose all 24 slots as aliases, in the order the UNION ALL depends on" in {
-    sentinelSchema.allFields should have size 24
+  "apply" should "expose all 25 slots as aliases, in the order the UNION ALL depends on" in {
+    sentinelSchema.allFields should have size 25
     sentinelSchema.allFields.map(_.getName) shouldBe expectedProjection.map(_._1)
   }
 
@@ -162,7 +164,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
     // about datasets still union with one that does: the column count and
     // types have to line up.
     val defaults = UnifiedResourceSchema()
-    defaults.allFields should have size 24
+    defaults.allFields should have size 25
     val rendered = ctx.renderInlined(JDSL.select(defaults.allFields: _*))
     rendered should include("'' as \"resourceType\"")
     rendered should include("cast(null as timestamp) as \"resourceCreationTime\"")
@@ -197,23 +199,24 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
   }
 
   it should "collapse the all-defaults projection down to one alias per distinct default" in {
-    // 24 slots, but only six structurally distinct default expressions, so the
-    // de-dup collapses the map to six entries. Worth pinning because it is
+    // 25 slots, but only seven structurally distinct default expressions, so the
+    // de-dup collapses the map to seven entries. Worth pinning because it is
     // surprising, and because it is what makes the keep-first rule observable at
-    // all: allFields stays at 24 while the translation map does not.
+    // all: allFields stays at 25 while the translation map does not.
     val defaults = UnifiedResourceSchema()
-    defaults.allFields should have size 24
+    defaults.allFields should have size 25
     translatedAliases(defaults) shouldBe Seq(
       "resourceType", // DSL.inline("")
       "resourceCreationTime", // cast(null as timestamp)
       "resourceOwnerId", // cast(null as int)
       "workflow_privilege", // cast(null as privilege_enum)
       "dataset_storage_path", // cast(null as varchar)
-      "is_versioned_resource_public" // cast(null as boolean)
+      "is_versioned_resource_public", // cast(null as boolean)
+      "workflow_default_view" // cast(null as default_view_enum)
     )
   }
 
-  it should "keep every distinct original when the caller supplies 24 distinct Fields" in {
+  it should "keep every distinct original when the caller supplies 25 distinct Fields" in {
     // Nothing to collapse here, which is the control case for the two tests
     // above: the shrinkage they observe comes from duplicate originals only.
     translatedAliases(sentinelSchema) shouldBe expectedProjection.map(_._1)
@@ -221,7 +224,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
 
   it should "drop exactly the duplicated slots of the production workflow projection" in {
     val workflowSchema = WorkflowSearchQueryBuilder.mappedResourceSchema
-    workflowSchema.allFields should have size 24
+    workflowSchema.allFields should have size 25
     val aliases = translatedAliases(workflowSchema)
     // `uid` duplicates ownerId (WORKFLOW_OF_USER.UID); the rest are slots the
     // builder left at their default, and the defaults collide by type.
@@ -241,7 +244,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
 
   "jOOQ Field equality" should "be structural, which is what makes the de-dup collapse anything" in {
     // If jOOQ ever switched to identity equality, translatedFieldSet would keep
-    // all 24 slots and translateRecord would start reading duplicated columns —
+    // all 25 slots and translateRecord would start reading duplicated columns —
     // the tests above would flip, and this one says why.
     JDSL.cast(null, classOf[Integer]) shouldBe JDSL.cast(null, classOf[Integer])
     JDSL.inline("") shouldBe JDSL.inline("")
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala
index 59dd8d9e19..53c2884067 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/WorkflowSearchQueryBuilderSpec.scala
@@ -20,7 +20,7 @@
 package org.apache.texera.web.resource.dashboard
 
 import org.apache.texera.dao.jooq.generated.Tables._
-import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, PrivilegeEnum}
 import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.DashboardWorkflow
 import org.jooq.impl.{DSL => JDSL}
 import org.jooq.{Record, SQLDialect}
@@ -68,6 +68,9 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
   // QueryParts structurally. The first test pins that assumption.
   private val pidField = JDSL.groupConcatDistinct(WORKFLOW_OF_PROJECT.PID)
   private val coverField = JDSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image")
+  // The select lists default_view under its own alias (not carried by the WORKFLOW POJO),
+  // and toEntryImpl reads it back by that alias — the record has to carry the column.
+  private val defaultViewField = WORKFLOW.DEFAULT_VIEW.as("workflow_default_view")
 
   private val ownerUid: Integer = Integer.valueOf(42)
   private val viewerUid: Integer = Integer.valueOf(43)
@@ -85,7 +88,8 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
       uidValue: Integer = ownerUid,
       privilege: PrivilegeEnum = PrivilegeEnum.WRITE,
       projects: String = "3,1,2",
-      cover: String = "cover-b64"
+      cover: String = "cover-b64",
+      defaultView: DefaultViewEnum = DefaultViewEnum.CANVAS
   ): Record = {
     val record = ctx.newRecord(
       WORKFLOW.WID,
@@ -95,7 +99,8 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
       WORKFLOW_USER_ACCESS.PRIVILEGE,
       USER.NAME,
       pidField,
-      coverField
+      coverField,
+      defaultViewField
     )
     record.set(WORKFLOW.WID, wid)
     record.set(WORKFLOW.NAME, "wf-name")
@@ -105,6 +110,7 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
     record.set(USER.NAME, "owner-name")
     record.set(pidField, projects)
     record.set(coverField, cover)
+    record.set(defaultViewField, defaultView)
     record
   }
 
@@ -201,6 +207,19 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
     workflowOf(translatedRecord(cover = null), ownerUid).coverImage shouldBe None
   }
 
+  it should "carry the default view off its own aliased column" in {
+    // The listing's select projects default_view separately (the WORKFLOW POJO the
+    // record maps into does not carry it), so toEntryImpl must read it back by alias.
+    workflowOf(
+      translatedRecord(defaultView = DefaultViewEnum.FORM),
+      ownerUid
+    ).workflow.getDefaultView shouldBe DefaultViewEnum.FORM
+    workflowOf(
+      translatedRecord(defaultView = DefaultViewEnum.CANVAS),
+      ownerUid
+    ).workflow.getDefaultView shouldBe DefaultViewEnum.CANVAS
+  }
+
   it should "tag the entry as a workflow and leave the other payload slots empty" in {
     val entry = WorkflowSearchQueryBuilder.toEntryImpl(ownerUid, translatedRecord())
     entry.resourceType shouldBe "workflow"
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
index c2852fc654..1fd9c406f3 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/file/WorkflowResourceSpec.scala
@@ -29,7 +29,7 @@ import org.apache.texera.dao.jooq.generated.Tables.{
   WORKFLOW_USER_CLONES,
   WORKFLOW_VERSION
 }
-import org.apache.texera.dao.jooq.generated.enums.{PrivilegeEnum, UserRoleEnum}
+import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, PrivilegeEnum, UserRoleEnum}
 import org.apache.texera.dao.jooq.generated.tables.daos.{UserDao, WorkflowUserAccessDao}
 import org.apache.texera.dao.jooq.generated.tables.pojos.{
   Project,
@@ -43,6 +43,7 @@ import org.apache.texera.web.resource.dashboard.user.project.ProjectResource
 import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource
 import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.{
   DashboardWorkflow,
+  DefaultViewRequest,
   WorkflowIDs,
   WorkflowWithPrivilege
 }
@@ -1300,4 +1301,221 @@ class WorkflowResourceSpec
     assert(workflowNamesOf(sessionUser1).isEmpty)
   }
 
+  // ---------------------------------------------------------------------------
+  // Form View: the per-workflow default view (canvas or form).
+  // ---------------------------------------------------------------------------
+
+  // duplicateWorkflow runs assignNewOperatorIds over the content, which requires a
+  // real `operators` array, so the toy content used elsewhere in this spec won't do.
+  private val contentWithOperators =
+    """{"operators":[{"operatorID":"Limit-operator-1","operatorType":"Limit"}],""" +
+      """"operatorPositions":{},"links":[],"commentBoxes":[],"settings":{}}"""
+
+  /** Persist a fresh workflow owned by user 1 and return its wid. */
+  private def persistFreshWorkflow(
+      name: String,
+      content: String = contentWithOperators
+  ): Integer = {
+    val workflow = new Workflow()
+    workflow.setName(name)
+    workflow.setContent(content)
+    workflowResource.persistWorkflow(workflow, sessionUser1)
+    workflow.getWid
+  }
+
+  private def defaultView(wid: Integer): DefaultViewEnum =
+    getDSLContext
+      .select(WORKFLOW.DEFAULT_VIEW)
+      .from(WORKFLOW)
+      .where(WORKFLOW.WID.eq(wid))
+      .fetchOne()
+      .value1()
+
+  private def contentOf(wid: Integer): String =
+    getDSLContext
+      .select(WORKFLOW.CONTENT)
+      .from(WORKFLOW)
+      .where(WORKFLOW.WID.eq(wid))
+      .fetchOne()
+      .value1()
+
+  private def lastModifiedOf(wid: Integer): Timestamp =
+    getDSLContext
+      .select(WORKFLOW.LAST_MODIFIED_TIME)
+      .from(WORKFLOW)
+      .where(WORKFLOW.WID.eq(wid))
+      .fetchOne()
+      .value1()
+
+  "/set-default-view API" should "switch the default view to form and back to canvas" in {
+    val wid = persistFreshWorkflow("param_toggle")
+    assert(defaultView(wid) == DefaultViewEnum.CANVAS, "a new workflow must default to the canvas")
+
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+    assert(defaultView(wid) == DefaultViewEnum.FORM)
+
+    workflowResource.setDefaultView(wid, DefaultViewRequest("CANVAS"), sessionUser1)
+    assert(defaultView(wid) == DefaultViewEnum.CANVAS)
+  }
+
+  it should "reject a user without write access" in {
+    val wid = persistFreshWorkflow("param_no_access")
+
+    assertThrows[ForbiddenException] {
+      workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser2)
+    }
+    assert(defaultView(wid) == DefaultViewEnum.CANVAS)
+  }
+
+  it should "reject an invalid or missing view value" in {
+    val wid = persistFreshWorkflow("param_invalid")
+
+    assertThrows[BadRequestException] {
+      workflowResource.setDefaultView(wid, DefaultViewRequest("SIDEBAR"), sessionUser1)
+    }
+    // A missing/null body value must be a 400, not a 500 (lookupLiteral returns null, not NPE).
+    assertThrows[BadRequestException] {
+      workflowResource.setDefaultView(wid, DefaultViewRequest(null), sessionUser1)
+    }
+    assert(defaultView(wid) == DefaultViewEnum.CANVAS)
+  }
+
+  // A plain save (persistWorkflow) only writes the fields the client sends -- name,
+  // description, content, is_public -- and never `default_view`, so saving the canvas must
+  // not reset the default view. The edit payload mirrors what the frontend sends.
+  it should "survive a subsequent save of the workflow" in {
+    val wid = persistFreshWorkflow("param_survives_save")
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+
+    val edit = new Workflow()
+    edit.setWid(wid)
+    edit.setName("param_survives_save_edited")
+    edit.setContent("{\"operators\":[],\"links\":[]}")
+    edit.setIsPublic(false)
+    workflowResource.persistWorkflow(edit, sessionUser1)
+
+    assert(
+      defaultView(wid) == DefaultViewEnum.FORM,
+      "saving the canvas must not reset the default view"
+    )
+  }
+
+  // A biologist's path is hub -> clone -> use, so a copy has to stay usable.
+  it should "be inherited by a duplicated workflow" in {
+    val wid = persistFreshWorkflow("param_source")
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+
+    val copies = workflowResource.duplicateWorkflow(WorkflowIDs(List(wid), None), sessionUser1)
+
+    assert(copies.length == 1)
+    assert(
+      defaultView(copies.head.workflow.getWid) == DefaultViewEnum.FORM,
+      "the copy must keep the preference"
+    )
+  }
+
+  // The hub's clone button goes through cloneWorkflow (not duplicateWorkflow); a cloned
+  // form-default workflow must stay form-default so the copy opens straight into its form.
+  it should "be inherited by a workflow cloned through cloneWorkflow" in {
+    val wid =
+      seedWorkflow(sessionUser1, "clone-formview-src", "d", contentWithOperator).workflow.getWid
+    workflowResource.makePublic(wid, sessionUser1)
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+
+    val newWid = workflowResource.cloneWorkflow(wid, sessionUser2, cloneRequest)
+
+    assert(defaultView(newWid) == DefaultViewEnum.FORM, "the clone must keep the default view")
+  }
+
+  // Both views load a workflow through this endpoint, and the client needs the default view
+  // to know which one to open first. Leaving the value out of the payload left the client
+  // guessing, so it is worth pinning down.
+  it should "be reported by the endpoint both views load through" in {
+    val wid = persistFreshWorkflow("param_retrieve")
+    assert(
+      workflowResource.retrieveWorkflow(wid, sessionUser1).defaultView == DefaultViewEnum.CANVAS
+    )
+
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+
+    assert(workflowResource.retrieveWorkflow(wid, sessionUser1).defaultView == DefaultViewEnum.FORM)
+  }
+
+  it should "leave a duplicate of a plain workflow defaulting to the canvas" in {
+    val wid = persistFreshWorkflow("plain_source")
+
+    val copies = workflowResource.duplicateWorkflow(WorkflowIDs(List(wid), None), sessionUser1)
+
+    assert(copies.length == 1)
+    assert(defaultView(copies.head.workflow.getWid) == DefaultViewEnum.CANVAS)
+  }
+
+  // Setting the preference updates only its own column, so a mere change must not bump the
+  // workflow's last-modified time (which would reorder the dashboard's "recent" listing).
+  it should "not change last_modified_time when the default view is set" in {
+    val wid = persistFreshWorkflow("param_mtime")
+    val before = lastModifiedOf(wid)
+
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+    assert(
+      lastModifiedOf(wid) == before,
+      "setting the default view must not bump last_modified_time"
+    )
+
+    workflowResource.setDefaultView(wid, DefaultViewRequest("CANVAS"), sessionUser1)
+    assert(lastModifiedOf(wid) == before, "setting it back must not bump last_modified_time")
+  }
+
+  // The dashboard listing (GET /workflow/list) selects specific columns, so it has to include
+  // default_view explicitly or every listed workflow would report the POJO default (null).
+  it should "be reported by the workflow listing endpoint" in {
+    val wid = persistFreshWorkflow("param_list")
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+
+    val listed =
+      workflowResource.retrieveWorkflowsBySessionUser(sessionUser1).find(_.workflow.getWid == wid)
+
+    assert(listed.isDefined)
+    assert(
+      listed.get.workflow.getDefaultView == DefaultViewEnum.FORM,
+      "the listing must carry the default view"
+    )
+  }
+
+  // The hub loads a public workflow through retrievePublicWorkflow, and a clone opens
+  // straight into the form only when that response says the source defaults to the form.
+  it should "be reported by retrievePublicWorkflow for a public workflow" in {
+    val workflow = new Workflow()
+    workflow.setName("param_public_retrieve")
+    workflow.setContent(contentWithOperators)
+    workflow.setIsPublic(true)
+    workflowResource.persistWorkflow(workflow, sessionUser1)
+    val wid = workflow.getWid
+
+    assert(workflowResource.retrievePublicWorkflow(wid).defaultView == DefaultViewEnum.CANVAS)
+
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+
+    assert(workflowResource.retrievePublicWorkflow(wid).defaultView == DefaultViewEnum.FORM)
+  }
+
+  // Switching the default back to canvas only changes the preference; the author's setup lives
+  // in content under `formBinding` and must survive so switching back to form restores it.
+  it should "keep the form definition in content when the default view is set back to canvas" in {
+    val withBinding =
+      """{"operators":[{"operatorID":"Limit-operator-1","operatorType":"Limit"}],""" +
+        """"operatorPositions":{},"links":[],"commentBoxes":[],"settings":{},""" +
+        """"formBinding":{"exposed":["Limit-operator-1"]}}"""
+    val wid = persistFreshWorkflow("param_keep_def", withBinding)
+    workflowResource.setDefaultView(wid, DefaultViewRequest("FORM"), sessionUser1)
+
+    workflowResource.setDefaultView(wid, DefaultViewRequest("CANVAS"), sessionUser1)
+
+    assert(defaultView(wid) == DefaultViewEnum.CANVAS)
+    assert(
+      contentOf(wid).contains("formBinding"),
+      "switching back to canvas must not erase the form definition"
+    )
+  }
+
 }
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala
index 15a3bb6654..91b0b1c1be 100644
--- a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowVersionResourceSpec.scala
@@ -23,7 +23,7 @@ import org.apache.texera.amber.util.JSONUtils.objectMapper
 import org.apache.texera.auth.SessionUser
 import org.apache.texera.dao.MockTexeraDB
 import org.apache.texera.dao.jooq.generated.Tables
-import org.apache.texera.dao.jooq.generated.enums.PrivilegeEnum
+import org.apache.texera.dao.jooq.generated.enums.{DefaultViewEnum, PrivilegeEnum}
 import org.apache.texera.dao.jooq.generated.tables.daos.{
   UserDao,
   WorkflowDao,
@@ -455,6 +455,39 @@ class WorkflowVersionResourceSpec
     cloned.getName should include("_copy")
   }
 
+  it should "inherit the source workflow's default view" in {
+    val workflowContent =
+      """{"operators":[{"operatorID":"CSVFileScan-operator-a","operatorType":"CSVFileScan"}],"links":[]}"""
+    testWorkflow.setContent(workflowContent)
+    testWorkflow.setDefaultView(DefaultViewEnum.FORM)
+    workflowDao.update(testWorkflow)
+    val version = WorkflowVersionResource.insertNewVersion(testWorkflowWid, "[]")
+
+    val newWid = resource.cloneVersion(
+      version.getVid,
+      session(owner),
+      Map("displayedVersionId" -> 1).asJava
+    )
+
+    workflowDao.fetchOneByWid(newWid).getDefaultView shouldBe DefaultViewEnum.FORM
+  }
+
+  it should "leave the clone defaulting to canvas when the source does" in {
+    val workflowContent =
+      """{"operators":[{"operatorID":"CSVFileScan-operator-a","operatorType":"CSVFileScan"}],"links":[]}"""
+    testWorkflow.setContent(workflowContent)
+    workflowDao.update(testWorkflow)
+    val version = WorkflowVersionResource.insertNewVersion(testWorkflowWid, "[]")
+
+    val newWid = resource.cloneVersion(
+      version.getVid,
+      session(owner),
+      Map("displayedVersionId" -> 1).asJava
+    )
+
+    workflowDao.fetchOneByWid(newWid).getDefaultView shouldBe DefaultViewEnum.CANVAS
+  }
+
   // ─── version-importance helpers (pure JSON/timestamp logic) ────────────────
 
   private val isSnapshotImportant = PrivateMethod[Boolean](Symbol("isSnapshotImportant"))
diff --git a/sql/changelog.xml b/sql/changelog.xml
index b57ec95ca4..8427de8170 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -124,6 +124,11 @@
         <sqlFile path="sql/updates/42.sql"/>
     </changeSet>
 
+    <!-- Form View: per-workflow default view (canvas or form) -->
+    <changeSet id="44" author="yangzhang75">
+        <sqlFile path="sql/updates/44.sql"/>
+    </changeSet>
+
     <!-- example changeSet
     <changeSet id="1" author="author">
         <sqlFile path="sql/updates/1.sql"/>
diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql
index acd4eef817..e4efa5277b 100644
--- a/sql/texera_ddl.sql
+++ b/sql/texera_ddl.sql
@@ -93,6 +93,7 @@ DROP TYPE IF EXISTS user_role_enum CASCADE;
 DROP TYPE IF EXISTS privilege_enum CASCADE;
 DROP TYPE IF EXISTS action_enum CASCADE;
 DROP TYPE IF EXISTS provider_type_enum CASCADE;
+DROP TYPE IF EXISTS default_view_enum CASCADE;
 
 CREATE TYPE user_role_enum AS ENUM ('INACTIVE', 'RESTRICTED', 'REGULAR', 'ADMIN');
 CREATE TYPE action_enum AS ENUM ('like', 'unlike', 'view', 'clone');
@@ -100,6 +101,7 @@ CREATE TYPE privilege_enum AS ENUM ('NONE', 'READ', 'WRITE');
 CREATE TYPE workflow_computing_unit_type_enum AS ENUM ('local', 'kubernetes');
 CREATE TYPE provider_type_enum AS ENUM ('LOCAL', 'GOOGLE');
 CREATE TYPE user_warehouse_flavor_enum AS ENUM ('local', 'aws');
+CREATE TYPE default_view_enum AS ENUM ('CANVAS', 'FORM');
 
 -- ============================================
 -- 5. Create tables
@@ -166,7 +168,10 @@ CREATE TABLE IF NOT EXISTS workflow
     content            TEXT NOT NULL,
     creation_time      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
     last_modified_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-    is_public          BOOLEAN NOT NULL DEFAULT false
+    is_public          BOOLEAN NOT NULL DEFAULT false,
+    -- Which view the workflow opens in by default (CANVAS or FORM); the form's definition
+    -- lives in workflow.content (`formBinding`).
+    default_view   default_view_enum NOT NULL DEFAULT 'CANVAS'
     );
 
 -- workflow_of_user
diff --git a/sql/updates/44.sql b/sql/updates/44.sql
new file mode 100644
index 0000000000..4be3264432
--- /dev/null
+++ b/sql/updates/44.sql
@@ -0,0 +1,39 @@
+/*
+ * 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.
+ */
+
+\c texera_db
+
+SET search_path TO texera_db;
+
+BEGIN;
+
+-- Which view a workflow opens in by default (CANVAS or FORM). The form's definition lives
+-- in workflow.content under `formBinding`; only this preference is denormalized so listing
+-- endpoints need not parse every row's content. It never gates availability -- the form is
+-- reachable for every workflow.
+DO $$ BEGIN
+    CREATE TYPE default_view_enum AS ENUM ('CANVAS', 'FORM');
+EXCEPTION
+    WHEN duplicate_object THEN NULL;
+END $$;
+
+ALTER TABLE workflow
+    ADD COLUMN IF NOT EXISTS default_view default_view_enum NOT NULL DEFAULT 'CANVAS';
+
+COMMIT;