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 cfe3f381a41..ee29ce417f4 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
@@ -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: whether the workflow also offers a Form View, so the listing can
+ // mark the row and route it accordingly.
+ workflowIsFormView: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean])
): 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"),
+ workflowIsFormView -> workflowIsFormView.as("workflow_is_form_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 b44ccaf30cc..77153a06b34 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
@@ -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"),
+ workflowIsFormView = WORKFLOW.IS_FORM_VIEW.as("workflow_is_form_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 Form View marker on every refresh.
+ val w = record.into(WORKFLOW).into(classOf[Workflow])
+ w.setIsFormView(record.get("workflow_is_form_view", classOf[java.lang.Boolean]) == true)
+ 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 0dd3cfc1b6d..7098442f77d 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.IS_FORM_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 315418516d3..a656813ecfa 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
@@ -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
@@ -138,7 +138,9 @@ object WorkflowResource {
creationTime: Timestamp,
lastModifiedTime: Timestamp,
isPublished: Boolean,
- readonly: Boolean
+ readonly: Boolean,
+ // Whether this workflow also offers a Form View; both views load through this endpoint.
+ isFormView: Boolean
)
case class WorkflowIDs(wids: List[Integer], pid: Option[Integer])
@@ -193,7 +195,7 @@ object WorkflowResource {
}
}
- def baseWorkflowSelect(): SelectOnConditionStep[Record10[
+ def baseWorkflowSelect(): SelectOnConditionStep[Record11[
Integer,
String,
String,
@@ -203,7 +205,8 @@ object WorkflowResource {
Integer,
String,
String,
- String
+ String,
+ java.lang.Boolean
]] = {
context
.select(
@@ -216,7 +219,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.IS_FORM_VIEW
)
.from(WORKFLOW)
.leftJoin(WORKFLOW_USER_ACCESS)
@@ -232,7 +236,7 @@ object WorkflowResource {
}
def mapWorkflowEntries(
- workflowEntries: Result[Record10[
+ workflowEntries: Result[Record11[
Integer,
String,
String,
@@ -242,7 +246,8 @@ object WorkflowResource {
Integer,
String,
String,
- String
+ String,
+ java.lang.Boolean
]],
uid: Integer
): List[DashboardWorkflow] = {
@@ -386,7 +391,8 @@ class WorkflowResource extends LazyLogging {
WORKFLOW.LAST_MODIFIED_TIME,
WORKFLOW_USER_ACCESS.PRIVILEGE,
WORKFLOW_OF_USER.UID,
- USER.NAME
+ USER.NAME,
+ WORKFLOW.IS_FORM_VIEW
)
.fetch()
mapWorkflowEntries(workflowEntries, user.getUid)
@@ -417,7 +423,8 @@ class WorkflowResource extends LazyLogging {
workflow.getCreationTime,
workflow.getLastModifiedTime,
workflow.getIsPublic,
- !WorkflowAccessResource.hasWriteAccess(wid, user.getUid)
+ !WorkflowAccessResource.hasWriteAccess(wid, user.getUid),
+ workflow.getIsFormView == true
)
} else {
throw new ForbiddenException("No sufficient access privilege.")
@@ -439,9 +446,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 +466,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 +477,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 `is_form_view` untouched --
+ * that column is owned by /enable-form-view and /disable-form-view alone -- so a save can
+ * never clobber a concurrent toggle. 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 +532,9 @@ class WorkflowResource extends LazyLogging {
assignNewOperatorIds(oldWorkflow.getContent),
null,
null,
- false
+ false,
+ // the Form View is part of the workflow, so a copy keeps it
+ oldWorkflow.getIsFormView
),
sessionUser
)
@@ -557,7 +584,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.getIsFormView
),
sessionUser
)
@@ -726,6 +755,39 @@ class WorkflowResource extends LazyLogging {
workflowDao.update(workflow)
}
+ /**
+ * Turn the Form View on for a workflow. Only this on/off flag lives in a column; the
+ * form's definition travels in workflow.content under `formBinding`, so turning it
+ * off does not erase it -- toggling back on restores the author's setup.
+ */
+ @PUT
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/enable-form-view/{wid}")
+ def enableFormView(@PathParam("wid") wid: Integer, @Auth user: SessionUser): Unit = {
+ setFormView(wid, user, enabled = true)
+ }
+
+ @PUT
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/disable-form-view/{wid}")
+ def disableFormView(@PathParam("wid") wid: Integer, @Auth user: SessionUser): Unit = {
+ setFormView(wid, user, enabled = false)
+ }
+
+ private def setFormView(wid: Integer, user: SessionUser, enabled: Boolean): Unit = {
+ if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have permission to modify workflow $wid")
+ }
+ // Update only this column. The flag is deliberately independent of content, so a toggle
+ // 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 flag flip.
+ context
+ .update(WORKFLOW)
+ .set(WORKFLOW.IS_FORM_VIEW, java.lang.Boolean.valueOf(enabled))
+ .where(WORKFLOW.WID.eq(wid))
+ .execute()
+ }
+
/** Returns the workflow's cover image; 404 if none set. */
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
@@ -848,7 +910,8 @@ class WorkflowResource extends LazyLogging {
workflow.getCreationTime,
workflow.getLastModifiedTime,
workflow.getIsPublic,
- readonly = true
+ readonly = true,
+ isFormView = workflow.getIsFormView == true
)
}
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 e0664b7c1d4..6e9c7821560 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,
+ // the version's content carries the Form View definition, so keep it usable
+ workflowVersion.getIsFormView
),
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 52890b4bf06..dcf5896eaa0 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,
+ workflowIsFormView = WORKFLOW.IS_FORM_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_is_form_view" -> WORKFLOW.IS_FORM_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,12 +199,12 @@ 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
+ // 25 slots, but only six structurally distinct default expressions, so the
// de-dup collapses the map to six 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)
@@ -213,7 +215,7 @@ class UnifiedResourceSchemaSpec extends AnyFlatSpec with Matchers {
)
}
- 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 +223,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 +243,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 59dd8d9e19f..a112622ecd1 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
@@ -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 is_form_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 isFormViewField = WORKFLOW.IS_FORM_VIEW.as("workflow_is_form_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",
+ formView: Boolean = false
): 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,
+ isFormViewField
)
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(isFormViewField, java.lang.Boolean.valueOf(formView))
record
}
@@ -201,6 +207,13 @@ class WorkflowSearchQueryBuilderSpec extends AnyFlatSpec with Matchers {
workflowOf(translatedRecord(cover = null), ownerUid).coverImage shouldBe None
}
+ it should "carry the Form View flag off its own aliased column" in {
+ // The listing's select projects is_form_view separately (the WORKFLOW POJO the
+ // record maps into does not carry it), so toEntryImpl must read it back by alias.
+ workflowOf(translatedRecord(formView = true), ownerUid).workflow.getIsFormView shouldBe true
+ workflowOf(translatedRecord(formView = false), ownerUid).workflow.getIsFormView shouldBe false
+ }
+
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 c2852fc6548..99b5615b31c 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
@@ -1300,4 +1300,191 @@ class WorkflowResourceSpec
assert(workflowNamesOf(sessionUser1).isEmpty)
}
+ // ---------------------------------------------------------------------------
+ // Form View: the per-workflow on/off flag.
+ // ---------------------------------------------------------------------------
+
+ // 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 isFormView(wid: Integer): Boolean =
+ getDSLContext
+ .select(WORKFLOW.IS_FORM_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()
+
+ "/enable-form-view API" should "turn the Form View on and back off" in {
+ val wid = persistFreshWorkflow("param_toggle")
+ assert(!isFormView(wid), "a new workflow must not be a Form View")
+
+ workflowResource.enableFormView(wid, sessionUser1)
+ assert(isFormView(wid))
+
+ workflowResource.disableFormView(wid, sessionUser1)
+ assert(!isFormView(wid))
+ }
+
+ it should "reject a user without write access" in {
+ val wid = persistFreshWorkflow("param_no_access")
+
+ assertThrows[ForbiddenException] {
+ workflowResource.enableFormView(wid, sessionUser2)
+ }
+ assert(!isFormView(wid))
+ }
+
+ // A plain save (persistWorkflow) only writes the fields the client sends -- name,
+ // description, content, is_public -- and never `is_form_view`, so saving the canvas must
+ // not turn the Form View back off. 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.enableFormView(wid, 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(isFormView(wid), "saving the canvas must not clear the flag")
+ }
+
+ // 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.enableFormView(wid, sessionUser1)
+
+ val copies = workflowResource.duplicateWorkflow(WorkflowIDs(List(wid), None), sessionUser1)
+
+ assert(copies.length == 1)
+ assert(isFormView(copies.head.workflow.getWid), "the copy must keep the flag")
+ }
+
+ // The hub's clone button goes through cloneWorkflow (not duplicateWorkflow); a cloned Form
+ // View must stay a Form View so the copy can open 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.enableFormView(wid, sessionUser1)
+
+ val newWid = workflowResource.cloneWorkflow(wid, sessionUser2, cloneRequest)
+
+ assert(isFormView(newWid), "the clone must keep the Form View flag")
+ }
+
+ // Both views load a workflow through this endpoint, and each has to know whether to
+ // offer the other. Leaving the flag out of the payload made the form redirect to the
+ // canvas every time, so it is worth pinning down.
+ it should "be reported by the endpoint both canvases load through" in {
+ val wid = persistFreshWorkflow("param_retrieve")
+ assert(!workflowResource.retrieveWorkflow(wid, sessionUser1).isFormView)
+
+ workflowResource.enableFormView(wid, sessionUser1)
+
+ assert(workflowResource.retrieveWorkflow(wid, sessionUser1).isFormView)
+ }
+
+ it should "leave a duplicate of a plain workflow without a Form View" in {
+ val wid = persistFreshWorkflow("plain_source")
+
+ val copies = workflowResource.duplicateWorkflow(WorkflowIDs(List(wid), None), sessionUser1)
+
+ assert(copies.length == 1)
+ assert(!isFormView(copies.head.workflow.getWid))
+ }
+
+ // Toggling the flag updates only its own column, so a mere on/off 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 Form View is toggled" in {
+ val wid = persistFreshWorkflow("param_mtime")
+ val before = lastModifiedOf(wid)
+
+ workflowResource.enableFormView(wid, sessionUser1)
+ assert(lastModifiedOf(wid) == before, "enabling must not bump last_modified_time")
+
+ workflowResource.disableFormView(wid, sessionUser1)
+ assert(lastModifiedOf(wid) == before, "disabling must not bump last_modified_time")
+ }
+
+ // The dashboard listing (GET /workflow/list) selects specific columns, so it has to include
+ // is_form_view explicitly or every listed workflow would report the POJO default (false).
+ it should "be reported by the workflow listing endpoint" in {
+ val wid = persistFreshWorkflow("param_list")
+ workflowResource.enableFormView(wid, sessionUser1)
+
+ val listed =
+ workflowResource.retrieveWorkflowsBySessionUser(sessionUser1).find(_.workflow.getWid == wid)
+
+ assert(listed.isDefined)
+ assert(listed.get.workflow.getIsFormView == true, "the listing must carry the Form View flag")
+ }
+
+ // The hub loads a public workflow through retrievePublicWorkflow, and a clone opens
+ // straight into the form only when that response says the source is a Form View.
+ 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).isFormView)
+
+ workflowResource.enableFormView(wid, sessionUser1)
+
+ assert(workflowResource.retrievePublicWorkflow(wid).isFormView)
+ }
+
+ // Turning the Form View off only clears the flag; the author's setup lives in content
+ // under `formBinding` and must survive so toggling back on restores it.
+ it should "keep the form definition in content when the Form View is turned off" 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.enableFormView(wid, sessionUser1)
+
+ workflowResource.disableFormView(wid, sessionUser1)
+
+ assert(!isFormView(wid))
+ assert(contentOf(wid).contains("formBinding"), "disabling 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 15a3bb66546..66a97f5a81c 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
@@ -455,6 +455,39 @@ class WorkflowVersionResourceSpec
cloned.getName should include("_copy")
}
+ it should "inherit the source workflow's Form View flag" in {
+ val workflowContent =
+ """{"operators":[{"operatorID":"CSVFileScan-operator-a","operatorType":"CSVFileScan"}],"links":[]}"""
+ testWorkflow.setContent(workflowContent)
+ testWorkflow.setIsFormView(true)
+ workflowDao.update(testWorkflow)
+ val version = WorkflowVersionResource.insertNewVersion(testWorkflowWid, "[]")
+
+ val newWid = resource.cloneVersion(
+ version.getVid,
+ session(owner),
+ Map("displayedVersionId" -> 1).asJava
+ )
+
+ workflowDao.fetchOneByWid(newWid).getIsFormView shouldBe true
+ }
+
+ it should "leave the clone without a Form View when the source has none" 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).getIsFormView shouldBe false
+ }
+
// ─── 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 b57ec95ca42..a7d982ea054 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -124,6 +124,11 @@
+
+
+
+
+