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 ee559048c97..02cf836dc84 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
@@ -77,6 +77,12 @@ object WorkflowResource {
)
private def workflowOfProjectDao = new WorkflowOfProjectDao(context.configuration)
+ /** Max length of a stored cover-image data URL. */
+ private val COVER_IMAGE_MAX_CHARS: Int = 4 * 1024 * 1024
+
+ /** JSON body/response for a workflow's cover image data URL. */
+ case class CoverImageRequest(image: String)
+
def getWorkflowName(wid: Integer): String = {
val workflow = workflowDao.fetchOneByWid(wid)
if (workflow == null) {
@@ -711,6 +717,73 @@ class WorkflowResource extends LazyLogging {
workflowDao.update(workflow)
}
+ /** Returns the workflow's cover image; 404 if none set. */
+ @GET
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{wid}/cover")
+ def getCoverImage(@PathParam("wid") wid: Integer, @Auth user: SessionUser): CoverImageRequest = {
+ if (!WorkflowAccessResource.hasReadAccess(wid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have access to workflow $wid")
+ }
+ val image = context
+ .select(WORKFLOW_COVER_IMAGE.IMAGE)
+ .from(WORKFLOW_COVER_IMAGE)
+ .where(WORKFLOW_COVER_IMAGE.WID.eq(wid))
+ .fetchOne(WORKFLOW_COVER_IMAGE.IMAGE)
+ if (image == null) {
+ throw new NotFoundException(s"Workflow $wid has no cover image")
+ }
+ CoverImageRequest(image)
+ }
+
+ /** Sets or replaces the workflow's cover image. */
+ @PUT
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{wid}/cover")
+ def setCoverImage(
+ @PathParam("wid") wid: Integer,
+ request: CoverImageRequest,
+ @Auth user: SessionUser
+ ): Unit = {
+ if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have permission to modify workflow $wid")
+ }
+ val image = Option(request.image).map(_.trim).getOrElse("")
+ if (image.isEmpty) {
+ throw new BadRequestException("Cover image is required")
+ }
+ if (!image.startsWith("data:image/")) {
+ throw new BadRequestException("Cover image must be an image data URL")
+ }
+ if (image.length > COVER_IMAGE_MAX_CHARS) {
+ throw new BadRequestException(
+ s"Cover image is too large (limit ${COVER_IMAGE_MAX_CHARS / (1024 * 1024)} MB)"
+ )
+ }
+ context
+ .insertInto(WORKFLOW_COVER_IMAGE)
+ .set(WORKFLOW_COVER_IMAGE.WID, wid)
+ .set(WORKFLOW_COVER_IMAGE.IMAGE, image)
+ .onConflict(WORKFLOW_COVER_IMAGE.WID)
+ .doUpdate()
+ .set(WORKFLOW_COVER_IMAGE.IMAGE, image)
+ .execute()
+ }
+
+ /** Removes the workflow's cover image. Idempotent. */
+ @DELETE
+ @RolesAllowed(Array("REGULAR", "ADMIN"))
+ @Path("/{wid}/cover")
+ def deleteCoverImage(@PathParam("wid") wid: Integer, @Auth user: SessionUser): Unit = {
+ if (!WorkflowAccessResource.hasWriteAccess(wid, user.getUid)) {
+ throw new ForbiddenException(s"You do not have permission to modify workflow $wid")
+ }
+ context
+ .deleteFrom(WORKFLOW_COVER_IMAGE)
+ .where(WORKFLOW_COVER_IMAGE.WID.eq(wid))
+ .execute()
+ }
+
@GET
@RolesAllowed(Array("REGULAR", "ADMIN"))
@Path("/type/{wid}")
diff --git a/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala
new file mode 100644
index 00000000000..14ff81eb4d8
--- /dev/null
+++ b/amber/src/test/scala/org/apache/texera/web/resource/dashboard/user/workflow/WorkflowResourceCoverSpec.scala
@@ -0,0 +1,253 @@
+/*
+ * 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.
+ */
+
+package org.apache.texera.web.resource.dashboard.user.workflow
+
+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.tables.daos.{
+ UserDao,
+ WorkflowDao,
+ WorkflowOfUserDao,
+ WorkflowUserAccessDao
+}
+import org.apache.texera.dao.jooq.generated.tables.pojos.{
+ User,
+ Workflow,
+ WorkflowOfUser,
+ WorkflowUserAccess
+}
+import org.apache.texera.web.resource.dashboard.user.workflow.WorkflowResource.CoverImageRequest
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach}
+
+import java.sql.Timestamp
+import javax.ws.rs.{BadRequestException, ForbiddenException, NotFoundException}
+
+class WorkflowResourceCoverSpec
+ extends AnyFlatSpec
+ with Matchers
+ with BeforeAndAfterAll
+ with BeforeAndAfterEach
+ with MockTexeraDB {
+
+ private val ownerUid = 1000 + scala.util.Random.nextInt(1000)
+ private val readerUid = 2000 + scala.util.Random.nextInt(1000)
+ private val strangerUid = 3000 + scala.util.Random.nextInt(1000)
+ private val testWid = 5000 + scala.util.Random.nextInt(1000)
+
+ private val sampleImage = "data:image/jpeg;base64,/9j/4AAQSkZJRg=="
+
+ private var owner: User = _
+ private var reader: User = _
+ private var stranger: User = _
+
+ private var userDao: UserDao = _
+ private var workflowDao: WorkflowDao = _
+ private var workflowOfUserDao: WorkflowOfUserDao = _
+ private var workflowUserAccessDao: WorkflowUserAccessDao = _
+ private var resource: WorkflowResource = _
+
+ override protected def beforeAll(): Unit = {
+ initializeDBAndReplaceDSLContext()
+ }
+
+ override protected def afterAll(): Unit = shutdownDB()
+
+ override protected def beforeEach(): Unit = {
+ userDao = new UserDao(getDSLContext.configuration())
+ workflowDao = new WorkflowDao(getDSLContext.configuration())
+ workflowOfUserDao = new WorkflowOfUserDao(getDSLContext.configuration())
+ workflowUserAccessDao = new WorkflowUserAccessDao(getDSLContext.configuration())
+ resource = new WorkflowResource()
+
+ owner = makeUser(ownerUid, "cover_owner")
+ reader = makeUser(readerUid, "cover_reader")
+ stranger = makeUser(strangerUid, "cover_stranger")
+
+ val workflow = new Workflow
+ workflow.setWid(testWid)
+ workflow.setName("cover_test_workflow")
+ workflow.setContent("{}")
+ workflow.setDescription("desc")
+ workflow.setIsPublic(false)
+ workflow.setCreationTime(new Timestamp(System.currentTimeMillis()))
+ workflow.setLastModifiedTime(new Timestamp(System.currentTimeMillis()))
+
+ cleanupTestData()
+
+ userDao.insert(owner)
+ userDao.insert(reader)
+ userDao.insert(stranger)
+ workflowDao.insert(workflow)
+
+ val ownership = new WorkflowOfUser
+ ownership.setUid(ownerUid)
+ ownership.setWid(testWid)
+ workflowOfUserDao.insert(ownership)
+
+ grantAccess(ownerUid, PrivilegeEnum.WRITE)
+ grantAccess(readerUid, PrivilegeEnum.READ)
+ }
+
+ override protected def afterEach(): Unit = cleanupTestData()
+
+ private def makeUser(uid: Int, name: String): User = {
+ val user = new User
+ user.setUid(uid)
+ user.setName(name)
+ user.setEmail(s"$name@test.com")
+ user.setPassword("password")
+ user
+ }
+
+ private def grantAccess(uid: Int, privilege: PrivilegeEnum): Unit = {
+ val access = new WorkflowUserAccess
+ access.setUid(uid)
+ access.setWid(testWid)
+ access.setPrivilege(privilege)
+ workflowUserAccessDao.insert(access)
+ }
+
+ private def session(user: User): SessionUser = new SessionUser(user)
+
+ private def cleanupTestData(): Unit = {
+ getDSLContext
+ .deleteFrom(WORKFLOW_COVER_IMAGE)
+ .where(WORKFLOW_COVER_IMAGE.WID.eq(testWid))
+ .execute()
+ getDSLContext
+ .deleteFrom(WORKFLOW_USER_ACCESS)
+ .where(WORKFLOW_USER_ACCESS.WID.eq(testWid))
+ .execute()
+ getDSLContext
+ .deleteFrom(WORKFLOW_OF_USER)
+ .where(WORKFLOW_OF_USER.WID.eq(testWid))
+ .execute()
+ getDSLContext
+ .deleteFrom(WORKFLOW)
+ .where(WORKFLOW.WID.eq(testWid))
+ .execute()
+ getDSLContext
+ .deleteFrom(USER)
+ .where(USER.UID.in(ownerUid, readerUid, strangerUid))
+ .execute()
+ }
+
+ "getCoverImage" should "throw NotFoundException when no cover is set" in {
+ assertThrows[NotFoundException] {
+ resource.getCoverImage(testWid, session(owner))
+ }
+ }
+
+ it should "return the stored cover after it is set" in {
+ resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner))
+ resource.getCoverImage(testWid, session(owner)).image shouldBe sampleImage
+ }
+
+ it should "be readable by a user with read access" in {
+ resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner))
+ resource.getCoverImage(testWid, session(reader)).image shouldBe sampleImage
+ }
+
+ it should "throw ForbiddenException for a user without read access" in {
+ resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner))
+ assertThrows[ForbiddenException] {
+ resource.getCoverImage(testWid, session(stranger))
+ }
+ }
+
+ "setCoverImage" should "replace an existing cover (upsert)" in {
+ resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner))
+ val replacement = "data:image/png;base64,iVBORw0KGgo="
+ resource.setCoverImage(testWid, CoverImageRequest(replacement), session(owner))
+ resource.getCoverImage(testWid, session(owner)).image shouldBe replacement
+ }
+
+ it should "throw BadRequestException for an empty or blank image" in {
+ assertThrows[BadRequestException] {
+ resource.setCoverImage(testWid, CoverImageRequest(""), session(owner))
+ }
+ assertThrows[BadRequestException] {
+ resource.setCoverImage(testWid, CoverImageRequest(" "), session(owner))
+ }
+ }
+
+ it should "throw BadRequestException for a null image" in {
+ assertThrows[BadRequestException] {
+ resource.setCoverImage(testWid, CoverImageRequest(null), session(owner))
+ }
+ }
+
+ it should "throw BadRequestException when the value is not an image data URL" in {
+ assertThrows[BadRequestException] {
+ resource.setCoverImage(
+ testWid,
+ CoverImageRequest("https://example.com/a.png"),
+ session(owner)
+ )
+ }
+ }
+
+ it should "throw BadRequestException when the data URL is too large" in {
+ val tooLarge = "data:image/jpeg;base64," + ("a" * (4 * 1024 * 1024 + 1))
+ assertThrows[BadRequestException] {
+ resource.setCoverImage(testWid, CoverImageRequest(tooLarge), session(owner))
+ }
+ }
+
+ it should "throw ForbiddenException for a user with only read access" in {
+ assertThrows[ForbiddenException] {
+ resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(reader))
+ }
+ }
+
+ it should "not persist a cover when validation fails" in {
+ assertThrows[BadRequestException] {
+ resource.setCoverImage(testWid, CoverImageRequest("not-a-data-url"), session(owner))
+ }
+ assertThrows[NotFoundException] {
+ resource.getCoverImage(testWid, session(owner))
+ }
+ }
+
+ "deleteCoverImage" should "remove an existing cover" in {
+ resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner))
+ resource.deleteCoverImage(testWid, session(owner))
+ assertThrows[NotFoundException] {
+ resource.getCoverImage(testWid, session(owner))
+ }
+ }
+
+ it should "be idempotent when no cover is set" in {
+ noException should be thrownBy resource.deleteCoverImage(testWid, session(owner))
+ }
+
+ it should "throw ForbiddenException for a user with only read access" in {
+ resource.setCoverImage(testWid, CoverImageRequest(sampleImage), session(owner))
+ assertThrows[ForbiddenException] {
+ resource.deleteCoverImage(testWid, session(reader))
+ }
+ // The cover must still be present after the rejected delete.
+ resource.getCoverImage(testWid, session(owner)).image shouldBe sampleImage
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
index f44f25597ec..a457f6e10f4 100644
--- a/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
+++ b/frontend/src/app/dashboard/component/user/list-item/card-item/card-item.component.html
@@ -36,6 +36,39 @@
[(ngModel)]="entry.checked"
(ngModelChange)="onCheckboxChange(entry)">
+
+
+
+
+
+
= {}): DashboardEntry {
@@ -69,16 +71,23 @@ describe("CardItemComponent", () => {
let component: CardItemComponent;
let fixture: ComponentFixture;
let workflowPersistService: Mocked;
+ let workflowCoverService: Mocked;
let datasetService: Mocked;
beforeEach(async () => {
const workflowPersistServiceSpy = { updateWorkflowName: vi.fn(), updateWorkflowDescription: vi.fn() };
+ const workflowCoverServiceSpy = {
+ getCover: vi.fn().mockReturnValue(of(undefined)),
+ setCoverFromFile: vi.fn(),
+ clearCover: vi.fn().mockReturnValue(of(undefined)),
+ };
const datasetServiceSpy = { getDatasetCoverUrl: vi.fn() };
await TestBed.configureTestingModule({
imports: [CardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule],
providers: [
{ provide: WorkflowPersistService, useValue: workflowPersistServiceSpy },
+ { provide: WorkflowCoverService, useValue: workflowCoverServiceSpy },
{ provide: DatasetService, useValue: datasetServiceSpy },
{ provide: UserService, useClass: StubUserService },
NzModalService,
@@ -89,6 +98,7 @@ describe("CardItemComponent", () => {
fixture = TestBed.createComponent(CardItemComponent);
component = fixture.componentInstance;
workflowPersistService = TestBed.inject(WorkflowPersistService) as unknown as Mocked;
+ workflowCoverService = TestBed.inject(WorkflowCoverService) as unknown as Mocked;
datasetService = TestBed.inject(DatasetService) as unknown as Mocked;
component.entry = makeWorkflowEntry();
fixture.detectChanges();
@@ -179,6 +189,103 @@ describe("CardItemComponent", () => {
expect(spy).toHaveBeenCalledTimes(1);
});
+ it("should show cover controls only for an owned workflow in private search", () => {
+ component.isPrivateSearch = true;
+ component.entry = makeWorkflowEntry({ workflow: { isOwner: true } } as any);
+ expect(component.canEditCover).toBe(true);
+
+ component.entry = makeWorkflowEntry({ workflow: { isOwner: false } } as any);
+ expect(component.canEditCover).toBe(false);
+
+ component.entry = makeWorkflowEntry({ workflow: { isOwner: true } } as any);
+ component.isPrivateSearch = false;
+ expect(component.canEditCover).toBe(false);
+ });
+
+ it("should load the stored cover on initialization and use it as the preview image", () => {
+ const cover = "data:image/jpeg;base64,abc";
+ workflowCoverService.getCover.mockReturnValue(of(cover));
+ component.entry = makeWorkflowEntry({ id: 7 });
+
+ component.ngOnChanges({ entry: { currentValue: component.entry } as any });
+
+ expect(workflowCoverService.getCover).toHaveBeenCalledWith(7);
+ expect(component.hasCustomImage).toBe(true);
+ expect(component.previewImage).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 });
+
+ component.ngOnChanges({ entry: { currentValue: component.entry } as any });
+
+ expect(component.hasCustomImage).toBe(false);
+ expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
+ });
+
+ it("should upload a selected image and use the returned data URL as the cover", async () => {
+ const dataUrl = "data:image/jpeg;base64,xyz";
+ workflowCoverService.setCoverFromFile.mockResolvedValue(dataUrl);
+ component.entry = makeWorkflowEntry({ id: 7 });
+ const file = new File(["x"], "pic.png", { type: "image/png" });
+
+ await component.onImageSelected({ target: { files: [file], value: "pic.png" } } as any);
+
+ expect(workflowCoverService.setCoverFromFile).toHaveBeenCalledWith(7, file);
+ expect(component.previewImage).toBe(dataUrl);
+ expect(component.hasCustomImage).toBe(true);
+ });
+
+ it("should reject a non-image file and not upload it", async () => {
+ const notificationService = TestBed.inject(NotificationService);
+ const errorSpy = vi.spyOn(notificationService, "error");
+ const file = new File(["x"], "notes.txt", { type: "text/plain" });
+
+ await component.onImageSelected({ target: { files: [file], value: "notes.txt" } } as any);
+
+ expect(workflowCoverService.setCoverFromFile).not.toHaveBeenCalled();
+ expect(errorSpy).toHaveBeenCalled();
+ });
+
+ it("should notify on upload failure and keep the previous preview image", async () => {
+ const notificationService = TestBed.inject(NotificationService);
+ const errorSpy = vi.spyOn(notificationService, "error");
+ workflowCoverService.setCoverFromFile.mockRejectedValue(new Error("boom"));
+ component.entry = makeWorkflowEntry({ id: 7 });
+ const file = new File(["x"], "pic.png", { type: "image/png" });
+
+ await component.onImageSelected({ target: { files: [file], value: "pic.png" } } as any);
+
+ expect(errorSpy).toHaveBeenCalled();
+ expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
+ });
+
+ it("should clear the cover and revert to the default image on reset", () => {
+ workflowCoverService.clearCover.mockReturnValue(of(undefined));
+ component.entry = makeWorkflowEntry({ id: 7 });
+ (component as any).customImage = "data:image/jpeg;base64,abc";
+
+ component.resetImage();
+
+ expect(workflowCoverService.clearCover).toHaveBeenCalledWith(7);
+ expect(component.hasCustomImage).toBe(false);
+ expect(component.previewImage).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
+ });
+
+ it("should notify and keep the cover when reset fails", () => {
+ const notificationService = TestBed.inject(NotificationService);
+ const errorSpy = vi.spyOn(notificationService, "error");
+ workflowCoverService.clearCover.mockReturnValue(throwError(() => new Error("boom")));
+ component.entry = makeWorkflowEntry({ id: 7 });
+ (component as any).customImage = "data:image/jpeg;base64,abc";
+
+ component.resetImage();
+
+ expect(errorSpy).toHaveBeenCalled();
+ expect(component.hasCustomImage).toBe(true);
+ });
+
it("should load the dataset cover into the preview when the entry has a cover", () => {
datasetService.getDatasetCoverUrl.mockReturnValue(of({ url: "https://cover.example/img.png" }));
component.entry = makeDatasetEntry({ id: 5, coverImageUrl: "cover/path.png" });
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 14f43f3ab46..9d7f6cb54fd 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
@@ -55,6 +55,7 @@ import { formatSize } from "src/app/common/util/size-formatter.util";
import { formatRelativeTime, formatCount } from "src/app/common/util/format.util";
import { DatasetService, DEFAULT_DATASET_NAME } from "../../../../service/user/dataset/dataset.service";
import { NotificationService } from "../../../../../common/service/notification/notification.service";
+import { WorkflowCoverService } from "../../../../service/user/workflow-cover/workflow-cover.service";
import {
HUB_DATASET_RESULT_DETAIL,
HUB_WORKFLOW_RESULT_DETAIL,
@@ -108,6 +109,10 @@ export class CardItemComponent implements OnChanges {
/** Resolved preview/cover image; stays the placeholder until a dataset cover loads. */
coverImageSrc: string = CardItemComponent.DEFAULT_PREVIEW_IMAGE;
+ /** The workflow's custom cover image data URL, if one has been set. */
+ private customImage?: string;
+ @ViewChild("backgroundInput") backgroundInput!: ElementRef;
+
@Input()
get entry(): DashboardEntry {
if (!this._entry) {
@@ -133,9 +138,66 @@ export class CardItemComponent implements OnChanges {
private hubService: HubService,
private downloadService: DownloadService,
private cdr: ChangeDetectorRef,
- private notificationService: NotificationService
+ private notificationService: NotificationService,
+ private workflowCoverService: WorkflowCoverService
) {}
+ /** The top image src: the user's custom cover if present, otherwise the default. */
+ get previewImage(): string {
+ return this.customImage ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE;
+ }
+
+ get hasCustomImage(): boolean {
+ return this.customImage !== undefined;
+ }
+
+ /** Whether the cover-image controls are shown: an editable workflow in private search. */
+ get canEditCover(): boolean {
+ return this.isPrivateSearch && this.entry.type === "workflow" && this.entry.workflow.isOwner;
+ }
+
+ openImagePicker(): void {
+ this.backgroundInput?.nativeElement.click();
+ }
+
+ async onImageSelected(event: Event): Promise {
+ const input = event.target as HTMLInputElement;
+ const file = input.files?.[0];
+ input.value = ""; // allow re-selecting the same file
+ if (!file) {
+ return;
+ }
+ if (!file.type.startsWith("image/")) {
+ this.notificationService.error("Please choose an image file.");
+ return;
+ }
+ if (typeof this.entry.id !== "number") {
+ return;
+ }
+ try {
+ this.customImage = await this.workflowCoverService.setCoverFromFile(this.entry.id, file);
+ this.cdr.markForCheck();
+ } catch (e) {
+ this.notificationService.error("Failed to set the cover image.");
+ }
+ }
+
+ resetImage(): void {
+ if (typeof this.entry.id !== "number") {
+ return;
+ }
+ this.workflowCoverService
+ .clearCover(this.entry.id)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: () => {
+ this.customImage = undefined;
+ this.cdr.markForCheck();
+ },
+ error: () => this.notificationService.error("Failed to reset the cover image."),
+ });
+ }
+
initializeEntry() {
if (this.entry.type === "workflow") {
if (typeof this.entry.id === "number") {
@@ -147,6 +209,13 @@ 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.cdr.markForCheck();
+ });
}
this.iconType = "project";
} else if (this.entry.type === "project") {
diff --git a/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.spec.ts b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.spec.ts
new file mode 100644
index 00000000000..751d9782895
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.spec.ts
@@ -0,0 +1,87 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
+import { WorkflowCoverService } from "./workflow-cover.service";
+import { AppSettings } from "../../../../common/app-setting";
+
+describe("WorkflowCoverService", () => {
+ let service: WorkflowCoverService;
+ let httpMock: HttpTestingController;
+ const coverUrl = (wid: number) => `${AppSettings.getApiEndpoint()}/workflow/${wid}/cover`;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [WorkflowCoverService],
+ });
+ service = TestBed.inject(WorkflowCoverService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it("getCover returns the stored image data URL", () => {
+ let result: string | undefined;
+ service.getCover(7).subscribe(image => (result = image));
+ const req = httpMock.expectOne(coverUrl(7));
+ expect(req.request.method).toBe("GET");
+ req.flush({ image: "data:image/jpeg;base64,abc" });
+ expect(result).toBe("data:image/jpeg;base64,abc");
+ });
+
+ it("getCover resolves to undefined when no cover exists (404)", () => {
+ let result: string | undefined = "unset";
+ service.getCover(7).subscribe(image => (result = image));
+ httpMock.expectOne(coverUrl(7)).flush(null, { status: 404, statusText: "Not Found" });
+ expect(result).toBeUndefined();
+ });
+
+ it("clearCover issues a DELETE", () => {
+ let completed = false;
+ service.clearCover(7).subscribe(() => (completed = true));
+ const req = httpMock.expectOne(coverUrl(7));
+ expect(req.request.method).toBe("DELETE");
+ req.flush(null);
+ expect(completed).toBe(true);
+ });
+
+ it("setCoverFromFile PUTs the resized data URL and resolves with it", async () => {
+ const dataUrl = "data:image/jpeg;base64,resized";
+ // The resize step relies on canvas/Image decoding, which jsdom cannot run;
+ // stub it so the test exercises the upload wiring deterministically.
+ (service as any).fileToResizedDataUrl = vi.fn().mockResolvedValue(dataUrl);
+ const file = new File(["x"], "pic.png", { type: "image/png" });
+
+ const resultPromise = service.setCoverFromFile(7, file);
+ // Let the stubbed resize promise settle so the HTTP request is issued.
+ await Promise.resolve();
+ await Promise.resolve();
+
+ const req = httpMock.expectOne(coverUrl(7));
+ expect(req.request.method).toBe("PUT");
+ expect(req.request.body).toEqual({ image: dataUrl });
+ req.flush(null);
+
+ await expect(resultPromise).resolves.toBe(dataUrl);
+ });
+});
diff --git a/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.ts b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.ts
new file mode 100644
index 00000000000..42cb070538b
--- /dev/null
+++ b/frontend/src/app/dashboard/service/user/workflow-cover/workflow-cover.service.ts
@@ -0,0 +1,87 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpClient } from "@angular/common/http";
+import { Injectable } from "@angular/core";
+import { Observable, firstValueFrom, of } from "rxjs";
+import { catchError, map } from "rxjs/operators";
+import { AppSettings } from "../../../../common/app-setting";
+
+export const WORKFLOW_COVER_URL = "workflow";
+
+/** Longest edge (px) a custom cover image is downscaled to before being stored. */
+const MAX_IMAGE_EDGE = 640;
+/** JPEG quality used when re-encoding a custom cover image for storage. */
+const IMAGE_QUALITY = 0.8;
+
+/** Stores an optional custom cover image per workflow on the backend, downscaled and re-encoded as a JPEG data URL. */
+@Injectable({
+ providedIn: "root",
+})
+export class WorkflowCoverService {
+ constructor(private http: HttpClient) {}
+
+ /** The workflow's custom cover image data URL, or undefined if it has none. */
+ getCover(wid: number): Observable {
+ return this.http.get<{ image: string }>(`${AppSettings.getApiEndpoint()}/${WORKFLOW_COVER_URL}/${wid}/cover`).pipe(
+ map(response => response.image),
+ catchError(() => of(undefined))
+ );
+ }
+
+ /** Downscales/re-encodes the chosen image, stores it as the workflow's cover, and resolves with the data URL. */
+ async setCoverFromFile(wid: number, file: File): Promise {
+ const dataUrl = await this.fileToResizedDataUrl(file);
+ await firstValueFrom(
+ this.http.put(`${AppSettings.getApiEndpoint()}/${WORKFLOW_COVER_URL}/${wid}/cover`, { image: dataUrl })
+ );
+ return dataUrl;
+ }
+
+ /** Removes the workflow's custom cover image. */
+ clearCover(wid: number): Observable {
+ return this.http.delete(`${AppSettings.getApiEndpoint()}/${WORKFLOW_COVER_URL}/${wid}/cover`);
+ }
+
+ private fileToResizedDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onerror = () => reject(new Error("Failed to read the selected image."));
+ reader.onload = () => {
+ const img = new Image();
+ img.onerror = () => reject(new Error("The selected file is not a valid image."));
+ img.onload = () => {
+ const scale = Math.min(1, MAX_IMAGE_EDGE / Math.max(img.width, img.height));
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.max(1, Math.round(img.width * scale));
+ canvas.height = Math.max(1, Math.round(img.height * scale));
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ reject(new Error("Unable to process the selected image."));
+ return;
+ }
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+ resolve(canvas.toDataURL("image/jpeg", IMAGE_QUALITY));
+ };
+ img.src = reader.result as string;
+ };
+ reader.readAsDataURL(file);
+ });
+ }
+}
diff --git a/sql/changelog.xml b/sql/changelog.xml
index e216caf3d00..ce7d50ff3ec 100644
--- a/sql/changelog.xml
+++ b/sql/changelog.xml
@@ -38,6 +38,11 @@
+
+
+
+
+