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/07/12 20:23:35 UTC

(texera) branch gh-readonly-queue/main/pr-6280-afa8af22b2ac0279fbcc0c610cfe54e3ee0ef0b8 created (now 490e5669f5)

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-6280-afa8af22b2ac0279fbcc0c610cfe54e3ee0ef0b8
in repository https://gitbox.apache.org/repos/asf/texera.git


      at 490e5669f5 fix(workflow): include coverImage in search and list API responses (#6280)

This branch includes the following new commits:

     new 490e5669f5 fix(workflow): include coverImage in search and list API responses (#6280)

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: fix(workflow): include coverImage in search and list API responses (#6280)

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-6280-afa8af22b2ac0279fbcc0c610cfe54e3ee0ef0b8
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 490e5669f52cd20b9516983e4d9525feaf91dbaa
Author: Martin Vu <12...@users.noreply.github.com>
AuthorDate: Sun Jul 12 13:23:13 2026 -0700

    fix(workflow): include coverImage in search and list API responses (#6280)
    
    ### What changes were proposed in this PR?
    
    Include workflow cover image data in the dashboard search response to
    prevent the frontend from needing additional requests to fetch cover
    images.
    
    This PR fixes the N+1 query issue where each workflow card was making a
    separate GET /workflow/{wid}/cover request to fetch its cover image.
    
    Before:
    
    · The frontend loads the workflow list (1 request)
    · Each card calls workflowCoverService.getCover() individually (N
    requests)
    · Total: 1 + N requests for N workflows
    <img width="1221" height="432" alt="Screenshot 2026-07-10 at 12 37
    25 AM"
    src="https://github.com/user-attachments/assets/cf747ba8-5fd8-485c-8a26-52a27a476204"
    />
    <img width="1221" height="622" alt="Screenshot 2026-07-10 at 12 37
    41 AM"
    src="https://github.com/user-attachments/assets/43a9852f-b308-4729-8449-1cfc1d859769"
    />
    
    
    After:
    
    · /dashboard/search and workflow responses now include the coverImage
    field in the response
    · Each card uses the bundled data directly
    · Total: 1 request
    <img width="1221" height="454" alt="Screenshot 2026-07-10 at 1 28 12 AM"
    src="https://github.com/user-attachments/assets/1b71a087-8b4e-4f26-b9c6-8960af2b1199"
    />
    <img width="2442" height="1232" alt="image"
    src="https://github.com/user-attachments/assets/ee55371e-38dd-40ec-8af6-5199afef9651"
    />
    <img width="2442" height="1232" alt="image"
    src="https://github.com/user-attachments/assets/17ef276c-f000-4180-815e-152f838b3353"
    />
    
    Changes made:
    
    1. Added coverImage field to DashboardWorkflow case class
    2. Modified baseWorkflowSelect() to left join WORKFLOW_COVER_IMAGE and
    select max(WORKFLOW_COVER_IMAGE.IMAGE).as("cover_image")
    3. Updated WorkflowSearchQueryBuilder to include coverImage in search
    results
    4. Updated frontend DashboardEntry and DashboardWorkflow interfaces to
    include coverImage data and use it directly in card rendering
    5. Replaced per-card getCover() calls with direct coverImage usage in
    card-item.component.ts
    
    ### Any related issues, documentation, discussions?
    
    Closes #5920
    Follow-up to #5704
    
    ### How was this PR tested?
    
    Manual Testing:
    
    · Verified coverImage field appears in search responses
    · Uploaded a test cover image via API and confirmed it appears in search
    response
    
    Automated Tests:
    
    · Added a test case to verify workflow cover images are included in
    search results.
    
    Tested with:
    
    sbt "project WorkflowExecutionService" "testOnly *WorkflowResourceSpec
    -- -z \"include workflow cover image in search results\""
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Co-authored with ChatGPT 5.5-mini
---
 .../resource/dashboard/UnifiedResourceSchema.scala |  6 ++--
 .../dashboard/WorkflowSearchQueryBuilder.scala     |  8 +++--
 .../dashboard/user/workflow/WorkflowResource.scala | 24 ++++++++-----
 .../dashboard/file/WorkflowResourceSpec.scala      | 41 ++++++++++++++++++++++
 .../component/user-dashboard-test-fixtures.ts      |  8 +++++
 .../card-item/card-item.component.spec.ts          | 15 ++++----
 .../list-item/card-item/card-item.component.ts     | 11 ++----
 .../dashboard/service/user/search.service.spec.ts  |  1 +
 frontend/src/app/dashboard/type/dashboard-entry.ts |  1 +
 .../dashboard/type/dashboard-workflow.interface.ts |  1 +
 10 files changed, 90 insertions(+), 26 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 fb6a8c928a..aa3681ae1f 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
@@ -76,7 +76,8 @@ object UnifiedResourceSchema {
       isDatasetPublic: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]),
       isDatasetDownloadable: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]),
       datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]),
-      datasetCoverImage: Field[String] = DSL.cast(null, classOf[String])
+      datasetCoverImage: Field[String] = DSL.cast(null, classOf[String]),
+      workflowCoverImage: Field[String] = DSL.cast(null, classOf[String])
   ): UnifiedResourceSchema = {
     new UnifiedResourceSchema(
       Seq(
@@ -102,7 +103,8 @@ object UnifiedResourceSchema {
         isDatasetPublic -> isDatasetPublic.as("is_dataset_public"),
         isDatasetDownloadable -> isDatasetDownloadable.as("is_dataset_downloadable"),
         datasetUserAccess -> datasetUserAccess.as("user_dataset_access"),
-        datasetCoverImage -> datasetCoverImage.as("cover_image")
+        datasetCoverImage -> datasetCoverImage.as("cover_image"),
+        workflowCoverImage -> workflowCoverImage.as("workflow_cover_image")
       )
     )
   }
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 f39453f651..9a08742c3e 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
@@ -52,7 +52,8 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
       uid = WORKFLOW_OF_USER.UID,
       ownerId = WORKFLOW_OF_USER.UID,
       userName = USER.NAME,
-      projectsOfWorkflow = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID)
+      projectsOfWorkflow = groupConcatDistinct(WORKFLOW_OF_PROJECT.PID),
+      workflowCoverImage = DSL.max(WORKFLOW_COVER_IMAGE.IMAGE).as("workflow_cover_image")
     )
   }
 
@@ -72,6 +73,8 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
       .on(WORKFLOW_OF_PROJECT.WID.eq(WORKFLOW.WID))
       .leftJoin(PROJECT_USER_ACCESS)
       .on(PROJECT_USER_ACCESS.PID.eq(WORKFLOW_OF_PROJECT.PID))
+      .leftJoin(WORKFLOW_COVER_IMAGE)
+      .on(WORKFLOW_COVER_IMAGE.WID.eq(WORKFLOW.WID))
 
     var condition: Condition = DSL.trueCondition()
     if (uid == null) {
@@ -162,7 +165,8 @@ object WorkflowSearchQueryBuilder extends SearchQueryBuilder {
           .map(number => Integer.valueOf(number))
           .toList
       },
-      record.into(USER).getUid
+      record.into(USER).getUid,
+      Option(record.get("workflow_cover_image", classOf[String]))
     )
     DashboardClickableFileEntry(SearchQueryBuilder.WORKFLOW_RESOURCE_TYPE, workflow = Some(dw))
   }
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 02cf836dc8..72d70d5cf7 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
@@ -41,8 +41,8 @@ import org.apache.texera.web.resource.dashboard.hub.EntityType
 import org.apache.texera.web.resource.dashboard.hub.HubResource.recordCloneAction
 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}
-import org.jooq.{Condition, DSLContext, Record9, Result, SelectOnConditionStep}
+import org.jooq.impl.DSL.{groupConcatDistinct, noCondition, max}
+import org.jooq.{Condition, DSLContext, Record10, Result, SelectOnConditionStep}
 
 import java.sql.Timestamp
 import java.util
@@ -125,7 +125,8 @@ object WorkflowResource {
       ownerName: String,
       workflow: Workflow,
       projectIDs: List[Integer],
-      ownerId: Integer
+      ownerId: Integer,
+      coverImage: Option[String]
   )
 
   case class WorkflowWithPrivilege(
@@ -191,7 +192,7 @@ object WorkflowResource {
     }
   }
 
-  def baseWorkflowSelect(): SelectOnConditionStep[Record9[
+  def baseWorkflowSelect(): SelectOnConditionStep[Record10[
     Integer,
     String,
     String,
@@ -200,6 +201,7 @@ object WorkflowResource {
     PrivilegeEnum,
     Integer,
     String,
+    String,
     String
   ]] = {
     context
@@ -212,7 +214,8 @@ object WorkflowResource {
         WORKFLOW_USER_ACCESS.PRIVILEGE,
         WORKFLOW_OF_USER.UID,
         USER.NAME,
-        groupConcatDistinct(WORKFLOW_OF_PROJECT.PID).as("projects")
+        groupConcatDistinct(WORKFLOW_OF_PROJECT.PID).as("projects"),
+        max(WORKFLOW_COVER_IMAGE.IMAGE).as("cover_image")
       )
       .from(WORKFLOW)
       .leftJoin(WORKFLOW_USER_ACCESS)
@@ -223,10 +226,12 @@ object WorkflowResource {
       .on(USER.UID.eq(WORKFLOW_OF_USER.UID))
       .leftJoin(WORKFLOW_OF_PROJECT)
       .on(WORKFLOW.WID.eq(WORKFLOW_OF_PROJECT.WID))
+      .leftJoin(WORKFLOW_COVER_IMAGE)
+      .on(WORKFLOW.WID.eq(WORKFLOW_COVER_IMAGE.WID))
   }
 
   def mapWorkflowEntries(
-      workflowEntries: Result[Record9[
+      workflowEntries: Result[Record10[
         Integer,
         String,
         String,
@@ -235,6 +240,7 @@ object WorkflowResource {
         PrivilegeEnum,
         Integer,
         String,
+        String,
         String
       ]],
       uid: Integer
@@ -255,7 +261,8 @@ object WorkflowResource {
           if (workflowRecord.component9() == null) List[Integer]()
           else
             workflowRecord.component9().split(',').map(str => Integer.valueOf(str)).toList,
-          workflowRecord.into(WORKFLOW_OF_USER).getUid
+          workflowRecord.into(WORKFLOW_OF_USER).getUid,
+          Option(workflowRecord.get("cover_image", classOf[String]))
         )
       )
       .asScala
@@ -584,7 +591,8 @@ class WorkflowResource extends LazyLogging {
         user.getName,
         workflowDao.fetchOneByWid(workflow.getWid),
         List[Integer](),
-        user.getUid
+        user.getUid,
+        None
       )
     }
 
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 385ad6bcf2..7e6f9572d3 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
@@ -26,6 +26,7 @@ import org.apache.texera.dao.jooq.generated.enums.UserRoleEnum
 import org.apache.texera.dao.jooq.generated.tables.daos.UserDao
 import org.apache.texera.dao.jooq.generated.tables.pojos.{Project, User, Workflow}
 import org.apache.texera.web.resource.dashboard.DashboardResource.SearchQueryParams
+import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.CoverImageRequest
 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.{
@@ -806,4 +807,44 @@ class WorkflowResourceSpec
     assert(resources.results.length == 3)
   }
 
+  it should "include workflow cover image in search results" in {
+    // Create workflow
+    workflowResource.persistWorkflow(testWorkflow1, sessionUser1)
+
+    // Set cover image
+    val workflowId =
+      workflowResource.retrieveWorkflowsBySessionUser(sessionUser1).head.workflow.getWid
+
+    val coverImage = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="
+    workflowResource.setCoverImage(
+      workflowId,
+      CoverImageRequest(coverImage),
+      sessionUser1
+    )
+
+    // Search workflows
+    val results =
+      dashboardResource.searchAllResourcesCall(
+        sessionUser1,
+        SearchQueryParams(resourceType = "workflow")
+      )
+
+    // Verify cover image is included in response
+    assert(results.results.length == 1)
+
+    val workflowEntry = results.results.head.workflow.get
+    assert(workflowEntry.coverImage.contains(coverImage))
+  }
+
+  it should "create a workflow with coverImage set to None" in {
+    val workflow = new Workflow()
+    workflow.setName("test_create_workflow")
+    workflow.setContent(exampleContent)
+
+    val result = workflowResource.createWorkflow(workflow, sessionUser1)
+
+    assert(result.workflow.getName == "test_create_workflow")
+    assert(result.coverImage.isEmpty)
+  }
+
 }
diff --git a/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts b/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts
index 09a158e336..d5ae29674f 100644
--- a/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts
+++ b/frontend/src/app/dashboard/component/user-dashboard-test-fixtures.ts
@@ -138,6 +138,7 @@ export const testWorkflowFileNameConflictEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [1],
     ownerId: 1,
+    coverImage: null,
   }),
   new DashboardEntry({
     workflow: testDownloadWorkflow2,
@@ -146,6 +147,7 @@ export const testWorkflowFileNameConflictEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [1, 2],
     ownerId: 1,
+    coverImage: null,
   }),
   new DashboardEntry({
     workflow: testDownloadWorkflow3,
@@ -154,6 +156,7 @@ export const testWorkflowFileNameConflictEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [1],
     ownerId: 2,
+    coverImage: null,
   }),
 ];
 
@@ -165,6 +168,7 @@ export const testWorkflowEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [1],
     ownerId: 1,
+    coverImage: null,
   }),
   new DashboardEntry({
     workflow: testWorkflow2,
@@ -173,6 +177,7 @@ export const testWorkflowEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [1, 2],
     ownerId: 1,
+    coverImage: null,
   }),
   new DashboardEntry({
     workflow: testWorkflow3,
@@ -181,6 +186,7 @@ export const testWorkflowEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [1],
     ownerId: 2,
+    coverImage: null,
   }),
   new DashboardEntry({
     workflow: testWorkflow4,
@@ -189,6 +195,7 @@ export const testWorkflowEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [3],
     ownerId: 2,
+    coverImage: null,
   }),
   new DashboardEntry({
     workflow: testWorkflow5,
@@ -197,6 +204,7 @@ export const testWorkflowEntries: DashboardEntry[] = [
     accessLevel: "Write",
     projectIDs: [3],
     ownerId: 3,
+    coverImage: null,
   }),
 ];
 
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
index fbb55114ee..ff174e93bc 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.spec.ts
@@ -202,24 +202,27 @@ describe("CardItemComponent", () => {
     expect(component.canEditCover).toBe(false);
   });
 
-  it("should load the stored cover on initialization and use it as the preview image", () => {
+  it("should use the stored cover image on initialization", () => {
     const cover = "data:image/jpeg;base64,abc";
-    workflowCoverService.getCover.mockReturnValue(of(cover));
-    component.entry = makeWorkflowEntry({ id: 7 });
+    const entry = makeWorkflowEntry({ id: 7 });
+    entry.coverImageUrl = cover;
 
+    component.entry = entry;
     component.ngOnChanges({ entry: { currentValue: component.entry } as any });
 
-    expect(workflowCoverService.getCover).toHaveBeenCalledWith(7);
+    expect(workflowCoverService.getCover).not.toHaveBeenCalled();
     expect(component.hasCustomImage).toBe(true);
     expect(component.coverImageSrc).toBe(cover);
   });
 
   it("should fall back to the default preview image when no cover is set", () => {
-    workflowCoverService.getCover.mockReturnValue(of(undefined));
-    component.entry = makeWorkflowEntry({ id: 7 });
+    const entry = makeWorkflowEntry({ id: 7 });
+    entry.coverImageUrl = undefined;
 
+    component.entry = entry;
     component.ngOnChanges({ entry: { currentValue: component.entry } as any });
 
+    expect(workflowCoverService.getCover).not.toHaveBeenCalled();
     expect(component.hasCustomImage).toBe(false);
     expect(component.coverImageSrc).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
   });
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
index 223bb5191e..7048daec4c 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.ts
@@ -195,6 +195,7 @@ export class CardItemComponent implements OnChanges {
 
   initializeEntry() {
     this.coverImageSrc = CardItemComponent.DEFAULT_PREVIEW_IMAGE;
+    this.customImage = undefined;
     if (this.entry.type === "workflow") {
       if (typeof this.entry.id === "number") {
         this.disableDelete = !this.entry.workflow.isOwner;
@@ -205,14 +206,8 @@ export class CardItemComponent implements OnChanges {
           this.entryLink = [HUB_WORKFLOW_RESULT_DETAIL, String(this.entry.id)];
         }
         this.size = this.entry.size;
-        this.workflowCoverService
-          .getCover(this.entry.id)
-          .pipe(untilDestroyed(this))
-          .subscribe(image => {
-            this.customImage = image;
-            this.coverImageSrc = image ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE;
-            this.cdr.markForCheck();
-          });
+        this.coverImageSrc = this.entry.coverImageUrl ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE;
+        this.customImage = this.entry.coverImageUrl ?? undefined;
       }
       this.iconType = "project";
     } else if (this.entry.type === "project") {
diff --git a/frontend/src/app/dashboard/service/user/search.service.spec.ts b/frontend/src/app/dashboard/service/user/search.service.spec.ts
index c80df0441b..994169a1ee 100644
--- a/frontend/src/app/dashboard/service/user/search.service.spec.ts
+++ b/frontend/src/app/dashboard/service/user/search.service.spec.ts
@@ -71,6 +71,7 @@ function makeWorkflowItem(wid: number, ownerId: number): SearchResultItem {
     projectIDs: [],
     accessLevel: "WRITE",
     ownerId,
+    coverImage: null,
   };
   return { resourceType: "workflow", workflow };
 }
diff --git a/frontend/src/app/dashboard/type/dashboard-entry.ts b/frontend/src/app/dashboard/type/dashboard-entry.ts
index b2949b080d..71c9a1515f 100644
--- a/frontend/src/app/dashboard/type/dashboard-entry.ts
+++ b/frontend/src/app/dashboard/type/dashboard-entry.ts
@@ -83,6 +83,7 @@ export class DashboardEntry {
       this.likeCount = 0;
       this.isLiked = false;
       this.accessibleUserIds = [];
+      this.coverImageUrl = value.coverImage ?? undefined;
     } else if (isDashboardProject(value)) {
       this.type = EntityType.Project;
       this.id = value.pid;
diff --git a/frontend/src/app/dashboard/type/dashboard-workflow.interface.ts b/frontend/src/app/dashboard/type/dashboard-workflow.interface.ts
index 1a2a340a0d..e984f29ac6 100644
--- a/frontend/src/app/dashboard/type/dashboard-workflow.interface.ts
+++ b/frontend/src/app/dashboard/type/dashboard-workflow.interface.ts
@@ -26,4 +26,5 @@ export interface DashboardWorkflow {
   projectIDs: number[];
   accessLevel: string;
   ownerId: number;
+  coverImage: string | null;
 }