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 @@ + + + + +