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/20 03:31:28 UTC
(texera) branch main updated: feat(frontend): add HuggingFace media output rendering in result panel (#5675)
This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new 57f522f1dd feat(frontend): add HuggingFace media output rendering in result panel (#5675)
57f522f1dd is described below
commit 57f522f1dd2d3c32013765f52082db9ae5c20d28
Author: Julie Cao <11...@users.noreply.github.com>
AuthorDate: Sun Jul 19 20:18:23 2026 -0700
feat(frontend): add HuggingFace media output rendering in result panel (#5675)
### What changes were proposed in this PR?
Render image, audio, and video outputs from HuggingFace tasks inline in
the workflow result panel instead of displaying raw data URLs as text.
**New file — `media-type.util.ts`:**
- `isImageUrl` — detects `data:image/` data URLs and common image
extensions (`.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`)
- `isAudioUrl` — detects `data:audio/` data URLs and common audio
extensions (`.mp3`, `.wav`, `.ogg`, `.m4a`, `.flac`)
- `isVideoUrl` — detects `data:video/` data URLs, common video
extensions (`.mp4`, `.webm`, `.ogg`), and the `fal.media` CDN host used
by fal.ai text-to-video outputs
**Changes to `result-table-frame.component.{ts,html}`:**
- Add `isImageCell` / `isAudioCell` / `isVideoCell` methods that
delegate to the detection helpers
- Show a media type icon (play/image/audio) in the table cell; the
actual `<img>`, `<audio controls>`, `<video controls>` elements render
inline in the row detail modal
- Precompute each cell's media type once per row when result data
arrives (`cellMediaTypes[rowIndex][columnIndex]`), instead of re-running
the detection helpers and getCell on every change-detection cycle
**Changes to `result-panel-modal.component.{ts,html}`:**
- Build `rowEntries` with per-field media metadata on modal open
- Render media fields inline in the row detail view with a
copy-to-clipboard fallback for the raw URL
- Fetch remote media through the backend's /huggingface/media-proxy
endpoint (with JWT auth) rather than loading cross-origin URLs directly
in the browser
- On a proxy failure (e.g. the backend's SSRF allowlist rejects the
URL), fall back to the plain-text view instead of loading the raw remote
URL directly
- Revoke allocated blob URLs on `ngOnDestroy` to avoid leaking object
URLs for the life of the page
**Changes to `result-panel-model.component.scss`:**
- Add modal-toolbar and row-detail styles for media display
**Changes to `operator-property-edit-frame.component.ts`:⋆
- Show validation messages (`validation.show = true`) for the
image/audio upload and prompt-column fields on HuggingFace operators
**New file — `media-type.util.spec.ts`:**
- 22 unit tests covering all three helpers across data URL prefixes,
file extensions (including case-insensitivity and query strings), the
`fal.media` CDN URL and empty/plain-string inputs
### Any related issues, documentation, discussions?
- Tracking issue: #5674
- Closes: #5674
- Stacked on: #5568
- Parent issue: #5041
### How was this PR tested?
- `media-type.util.spec.ts` (22 cases): data URL prefixes, file
extensions, case-insensitivity, query strings, the fal.media CDN URL,
cross-type rejection, and non-string input handling
- `result-table-frame.component.spec.ts`: media cell icon rendering —
Play Video/Play Audio/View Image indicators and plain-text fallback
- `result-panel-modal.component.spec.ts`: row-entry construction from
data URLs vs. remote URLs, blob-fetch success/failure, the SSRF-safe
proxy-failure fallback, clipboard copy, and inline video/image `src`
binding
- Run with `ng test`
### Was this PR authored or co-authored using generative AI tooling?
Co-authored with Claude Sonnet 4.6 in compliance with ASF guidelines
---------
Signed-off-by: Elliot Lin <36...@users.noreply.github.com>
Signed-off-by: Julie Cao <11...@users.noreply.github.com>
Co-authored-by: Elliot <36...@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <no...@anthropic.com>
Co-authored-by: Elliot Lin <36...@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <17...@users.noreply.github.com>
Co-authored-by: Anish Shivamurthy <an...@uci.edu>
Co-authored-by: Anish Shivamurthy <an...@gmail.com>
Co-authored-by: PG1204 <vi...@gmail.com>
Co-authored-by: Matthew B. <mg...@uci.edu>
---
.../src/app/common/util/media-type.util.spec.ts | 148 +++++++++++++
.../util/media-type.util.ts} | 22 +-
.../hugging-face-audio-upload.component.spec.ts | 186 +++++++++++++++-
.../hugging-face/hugging-face.component.html | 7 +-
.../hugging-face/hugging-face.component.spec.ts | 158 +++++++++++++-
.../hugging-face/hugging-face.component.ts | 27 +--
.../operator-property-edit-frame.component.spec.ts | 223 ++++++++++++++++++++
.../operator-property-edit-frame.component.ts | 12 ++
.../result-panel/result-panel-modal.component.html | 55 ++++-
.../result-panel-modal.component.spec.ts | 233 ++++++++++++++++++++-
.../result-panel/result-panel-modal.component.ts | 103 ++++++++-
.../result-panel/result-panel-model.component.scss | 51 +++++
.../result-table-frame.component.html | 33 ++-
.../result-table-frame.component.spec.ts | 69 ++++++
.../result-table-frame.component.ts | 44 +++-
15 files changed, 1320 insertions(+), 51 deletions(-)
diff --git a/frontend/src/app/common/util/media-type.util.spec.ts b/frontend/src/app/common/util/media-type.util.spec.ts
new file mode 100644
index 0000000000..457f05a39d
--- /dev/null
+++ b/frontend/src/app/common/util/media-type.util.spec.ts
@@ -0,0 +1,148 @@
+/**
+ * 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 { isAudioUrl, isImageUrl, isVideoUrl } from "./media-type.util";
+
+describe("isImageUrl", () => {
+ it("should return true for data:image/ data URLs", () => {
+ expect(isImageUrl("data:image/png;base64,abc123")).toBe(true);
+ expect(isImageUrl("data:image/jpeg;base64,abc123")).toBe(true);
+ expect(isImageUrl("data:image/webp;base64,abc123")).toBe(true);
+ });
+
+ it("should return true for common image file extensions", () => {
+ expect(isImageUrl("https://example.com/photo.png")).toBe(true);
+ expect(isImageUrl("https://example.com/photo.jpg")).toBe(true);
+ expect(isImageUrl("https://example.com/photo.jpeg")).toBe(true);
+ expect(isImageUrl("https://example.com/photo.gif")).toBe(true);
+ expect(isImageUrl("https://example.com/photo.webp")).toBe(true);
+ });
+
+ it("should be case-insensitive for extensions", () => {
+ expect(isImageUrl("https://example.com/photo.PNG")).toBe(true);
+ expect(isImageUrl("https://example.com/photo.JPG")).toBe(true);
+ });
+
+ it("should return true for URLs with query strings", () => {
+ expect(isImageUrl("https://example.com/photo.png?v=1")).toBe(true);
+ });
+
+ it("should return false for audio and video URLs", () => {
+ expect(isImageUrl("data:audio/mp3;base64,abc")).toBe(false);
+ expect(isImageUrl("data:video/mp4;base64,abc")).toBe(false);
+ expect(isImageUrl("https://example.com/clip.mp4")).toBe(false);
+ });
+
+ it("should return false for plain text strings", () => {
+ expect(isImageUrl("hello world")).toBe(false);
+ expect(isImageUrl("")).toBe(false);
+ });
+});
+
+describe("isAudioUrl", () => {
+ it("should return true for data:audio/ data URLs", () => {
+ expect(isAudioUrl("data:audio/mp3;base64,abc123")).toBe(true);
+ expect(isAudioUrl("data:audio/wav;base64,abc123")).toBe(true);
+ });
+
+ it("should return true for common audio file extensions", () => {
+ expect(isAudioUrl("https://example.com/clip.mp3")).toBe(true);
+ expect(isAudioUrl("https://example.com/clip.wav")).toBe(true);
+ expect(isAudioUrl("https://example.com/clip.ogg")).toBe(true);
+ expect(isAudioUrl("https://example.com/clip.m4a")).toBe(true);
+ expect(isAudioUrl("https://example.com/clip.flac")).toBe(true);
+ });
+
+ it("should be case-insensitive for extensions", () => {
+ expect(isAudioUrl("https://example.com/clip.MP3")).toBe(true);
+ expect(isAudioUrl("https://example.com/clip.WAV")).toBe(true);
+ });
+
+ it("should return true for URLs with query strings", () => {
+ expect(isAudioUrl("https://example.com/clip.mp3?token=xyz")).toBe(true);
+ });
+
+ it("should return false for image and video URLs", () => {
+ expect(isAudioUrl("data:image/png;base64,abc")).toBe(false);
+ expect(isAudioUrl("data:video/mp4;base64,abc")).toBe(false);
+ expect(isAudioUrl("https://example.com/photo.png")).toBe(false);
+ });
+
+ it("should return false for plain text strings", () => {
+ expect(isAudioUrl("hello world")).toBe(false);
+ expect(isAudioUrl("")).toBe(false);
+ });
+});
+
+describe("isVideoUrl", () => {
+ it("should return true for data:video/ data URLs", () => {
+ expect(isVideoUrl("data:video/mp4;base64,abc123")).toBe(true);
+ expect(isVideoUrl("data:video/webm;base64,abc123")).toBe(true);
+ });
+
+ it("should return true for common video file extensions", () => {
+ expect(isVideoUrl("https://example.com/clip.mp4")).toBe(true);
+ expect(isVideoUrl("https://example.com/clip.webm")).toBe(true);
+ expect(isVideoUrl("https://example.com/clip.ogv")).toBe(true);
+ });
+
+ it("should return true for fal.media CDN URLs", () => {
+ expect(isVideoUrl("https://v3b.fal.media/files/abc123/output.mp4")).toBe(true);
+ });
+
+ it("should be case-insensitive for extensions", () => {
+ expect(isVideoUrl("https://example.com/clip.MP4")).toBe(true);
+ expect(isVideoUrl("https://example.com/clip.WEBM")).toBe(true);
+ });
+
+ it("should return true for URLs with query strings", () => {
+ expect(isVideoUrl("https://example.com/clip.mp4?t=5")).toBe(true);
+ });
+
+ it("should return false for image and audio URLs", () => {
+ expect(isVideoUrl("data:image/png;base64,abc")).toBe(false);
+ expect(isVideoUrl("data:audio/mp3;base64,abc")).toBe(false);
+ expect(isVideoUrl("https://example.com/photo.jpg")).toBe(false);
+ });
+
+ it("should return false for plain text strings", () => {
+ expect(isVideoUrl("hello world")).toBe(false);
+ expect(isVideoUrl("")).toBe(false);
+ });
+
+ it("should return false for non-string types", () => {
+ expect(isVideoUrl(null as unknown as string)).toBe(false);
+ expect(isVideoUrl(undefined as unknown as string)).toBe(false);
+ expect(isVideoUrl(42 as unknown as string)).toBe(false);
+ });
+});
+
+describe("non-string type guard (shared)", () => {
+ it("isAudioUrl should return false for non-string types", () => {
+ expect(isAudioUrl(null as unknown as string)).toBe(false);
+ expect(isAudioUrl(undefined as unknown as string)).toBe(false);
+ expect(isAudioUrl(true as unknown as string)).toBe(false);
+ });
+
+ it("isImageUrl should return false for non-string types", () => {
+ expect(isImageUrl(null as unknown as string)).toBe(false);
+ expect(isImageUrl(undefined as unknown as string)).toBe(false);
+ expect(isImageUrl([] as unknown as string)).toBe(false);
+ });
+});
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss b/frontend/src/app/common/util/media-type.util.ts
similarity index 53%
copy from frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
copy to frontend/src/app/common/util/media-type.util.ts
index eb5ff4dece..26872f0bc1 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
+++ b/frontend/src/app/common/util/media-type.util.ts
@@ -17,9 +17,21 @@
* under the License.
*/
-.modal-body.content-body {
- overflow-x: auto;
- overflow-y: auto;
- height: 100%;
- width: 100%;
+export function isVideoUrl(value: string): boolean {
+ if (typeof value !== "string") return false;
+ return (
+ /^https?:\/\/.+\.(mp4|webm|ogv)(\?.*)?$/i.test(value) ||
+ value.startsWith("data:video/") ||
+ /^https:\/\/[a-z0-9-]+\.fal\.media\/files\//i.test(value)
+ );
+}
+
+export function isAudioUrl(value: string): boolean {
+ if (typeof value !== "string") return false;
+ return /^https?:\/\/.+\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/i.test(value) || value.startsWith("data:audio/");
+}
+
+export function isImageUrl(value: string): boolean {
+ if (typeof value !== "string") return false;
+ return /^https?:\/\/.+\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i.test(value) || value.startsWith("data:image/");
}
diff --git a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts
index f92300731a..d2ec9f681c 100644
--- a/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts
+++ b/frontend/src/app/workspace/component/hugging-face-audio-upload/hugging-face-audio-upload.component.spec.ts
@@ -17,10 +17,11 @@
* under the License.
*/
-import { TestBed } from "@angular/core/testing";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { FormControl } from "@angular/forms";
import { FieldTypeConfig } from "@ngx-formly/core";
+import { By } from "@angular/platform-browser";
import { AppSettings } from "../../../common/app-setting";
import { HuggingFaceAudioUploadComponent } from "./hugging-face-audio-upload.component";
@@ -121,6 +122,21 @@ describe("HuggingFaceAudioUploadComponent", () => {
formControl.setValue(" ");
expect(component.previewSrc).toBe("");
});
+
+ it("should return localPreviewUrl when a file has been selected but upload is in progress", async () => {
+ vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview");
+ vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined);
+
+ const file = new File(["audio"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ expect(component.previewSrc).toBe("blob:fake-preview");
+
+ httpTestingController
+ .expectOne(r => r.url.includes("/huggingface/upload-audio"))
+ .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+ });
});
// ── File upload ──
@@ -239,6 +255,43 @@ describe("HuggingFaceAudioUploadComponent", () => {
req.flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
await uploadPromise;
});
+
+ it("should discard stale upload response when cleared during upload", async () => {
+ vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview");
+ vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined);
+
+ const file = new File(["audio"], "clip.wav", { type: "audio/wav" });
+ const { event, input } = makeFileEventWithInput(file);
+ const uploadPromise = component.onFileSelected(event);
+
+ component.clearAudio(input);
+
+ httpTestingController
+ .expectOne(r => r.url.includes("/huggingface/upload-audio"))
+ .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+
+ expect(formControl.value).toBe("");
+ expect(component.fileName).toBe("");
+ });
+
+ it("should discard stale upload error when cleared during upload", async () => {
+ vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview");
+ vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined);
+
+ const file = new File(["audio"], "clip.wav", { type: "audio/wav" });
+ const { event, input } = makeFileEventWithInput(file);
+ const uploadPromise = component.onFileSelected(event);
+
+ component.clearAudio(input);
+
+ httpTestingController
+ .expectOne(r => r.url.includes("/huggingface/upload-audio"))
+ .error(new ProgressEvent("error"));
+ await uploadPromise;
+
+ expect(component.errorMessage).toBe("");
+ });
});
// ── clearAudio ──
@@ -433,6 +486,22 @@ describe("HuggingFaceAudioUploadComponent", () => {
expect(() => component.ngOnDestroy()).not.toThrow();
});
+ it("should revoke localPreviewUrl on destroy when upload preview exists", async () => {
+ const revokeSpy = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined);
+ vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-preview");
+
+ const file = new File(["audio"], "clip.wav", { type: "audio/wav" });
+ const uploadPromise = component.onFileSelected(makeFileEvent(file));
+
+ httpTestingController
+ .expectOne(r => r.url.includes("/huggingface/upload-audio"))
+ .flush({ path: "/tmp/clip.wav", fileName: "clip.wav" });
+ await uploadPromise;
+
+ component.ngOnDestroy();
+ expect(revokeSpy).toHaveBeenCalledWith("blob:fake-preview");
+ });
+
it("should revoke localPreviewUrl on destroy", async () => {
const blobUrl = "blob:http://localhost/local-preview";
vi.spyOn(URL, "createObjectURL").mockReturnValue(blobUrl);
@@ -574,4 +643,119 @@ describe("HuggingFaceAudioUploadComponent", () => {
expect(component.fileName).toBe("second.wav");
});
});
+
+ // ── Template rendering ──
+
+ describe("template rendering", () => {
+ let templateFixture: ComponentFixture<HuggingFaceAudioUploadComponent>;
+ let templateComponent: HuggingFaceAudioUploadComponent;
+
+ beforeEach(() => {
+ templateFixture = TestBed.createComponent(HuggingFaceAudioUploadComponent);
+ templateComponent = templateFixture.componentInstance;
+ const fc = new FormControl("");
+ templateComponent.field = { formControl: fc, key: "audioInput", model: {} } as unknown as FieldTypeConfig;
+ });
+
+ it("should render the file input", () => {
+ templateFixture.detectChanges();
+ expect(templateFixture.debugElement.query(By.css("input[type='file']"))).toBeTruthy();
+ });
+
+ it("should not render the preview section when there is no audio", () => {
+ templateFixture.detectChanges();
+ expect(templateFixture.debugElement.query(By.css(".hf-audio-preview"))).toBeNull();
+ });
+
+ it("should render the preview section when formControl has a data:audio value", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateFixture.detectChanges();
+ expect(templateFixture.debugElement.query(By.css(".hf-audio-preview"))).toBeTruthy();
+ expect(templateFixture.debugElement.query(By.css("audio"))).toBeTruthy();
+ });
+
+ it("should bind audio [src] to previewSrc for a data:audio value", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateFixture.detectChanges();
+ const audio = templateFixture.debugElement.query(By.css("audio")).nativeElement as HTMLAudioElement;
+ expect(audio.src).toContain("data:audio/wav");
+ });
+
+ it("should show fileName in the preview meta", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateFixture.detectChanges(); // ngOnInit runs here
+ templateComponent.fileName = "clip.wav";
+ templateFixture.detectChanges();
+ const span = templateFixture.debugElement.query(By.css(".hf-audio-meta span"));
+ expect((span.nativeElement as HTMLElement).textContent?.trim()).toBe("clip.wav");
+ });
+
+ it("should fall back to 'Selected audio' when fileName is empty and preview is shown", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateComponent.fileName = "";
+ templateFixture.detectChanges();
+ const span = templateFixture.debugElement.query(By.css(".hf-audio-meta span"));
+ expect((span.nativeElement as HTMLElement).textContent?.trim()).toBe("Selected audio");
+ });
+
+ it("should show the Uploading status span when isUploading is true and preview is visible", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateComponent.isUploading = true;
+ templateFixture.detectChanges();
+ const status = templateFixture.debugElement.query(By.css(".hf-audio-status"));
+ expect(status).toBeTruthy();
+ expect((status.nativeElement as HTMLElement).textContent?.trim()).toBe("Uploading...");
+ });
+
+ it("should hide the Uploading status span when isUploading is false", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateComponent.isUploading = false;
+ templateFixture.detectChanges();
+ expect(templateFixture.debugElement.query(By.css(".hf-audio-status"))).toBeNull();
+ });
+
+ it("should disable the Clear button when isUploading is true", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateComponent.isUploading = true;
+ templateFixture.detectChanges();
+ const btn = templateFixture.debugElement.query(By.css("button[nz-button]")).nativeElement as HTMLButtonElement;
+ expect(btn.disabled).toBe(true);
+ });
+
+ it("should enable the Clear button when isUploading is false", () => {
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateComponent.isUploading = false;
+ templateFixture.detectChanges();
+ const btn = templateFixture.debugElement.query(By.css("button[nz-button]")).nativeElement as HTMLButtonElement;
+ expect(btn.disabled).toBe(false);
+ });
+
+ it("should disable the file input when isUploading is true", () => {
+ templateComponent.isUploading = true;
+ templateFixture.detectChanges();
+ const input = templateFixture.debugElement.query(By.css("input[type='file']")).nativeElement as HTMLInputElement;
+ expect(input.disabled).toBe(true);
+ });
+
+ it("should show the error message when errorMessage is set", () => {
+ templateComponent.errorMessage = "Could not upload this audio file.";
+ templateFixture.detectChanges();
+ const errorEl = templateFixture.debugElement.query(By.css(".hf-audio-error"));
+ expect(errorEl).toBeTruthy();
+ expect((errorEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Could not upload this audio file.");
+ });
+
+ it("should not render the error div when errorMessage is empty", () => {
+ templateFixture.detectChanges();
+ expect(templateFixture.debugElement.query(By.css(".hf-audio-error"))).toBeNull();
+ });
+
+ it("should call clearAudio when the Clear button is clicked", () => {
+ const clearSpy = vi.spyOn(templateComponent, "clearAudio");
+ templateComponent.field.formControl.setValue("data:audio/wav;base64,abc123");
+ templateFixture.detectChanges();
+ templateFixture.debugElement.query(By.css("button[nz-button]")).triggerEventHandler("click", null);
+ expect(clearSpy).toHaveBeenCalled();
+ });
+ });
});
diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
index b44624721a..777111cc3e 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.html
@@ -69,13 +69,8 @@
(ngModelChange)="onSearchInput($event)" />
</nz-input-group>
<ng-template #searchClearTpl>
- <nz-spin
- *ngIf="searchLoading"
- nzSimple
- nzSize="small"
- style="display: inline-block; margin-right: 4px"></nz-spin>
<i
- *ngIf="searchText && !searchLoading"
+ *ngIf="searchText"
nz-icon
nzType="close-circle"
nzTheme="fill"
diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
index b85e71653e..51f7e8999c 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.spec.ts
@@ -17,7 +17,7 @@
* under the License.
*/
-import { ComponentFixture, TestBed, fakeAsync, tick } from "@angular/core/testing";
+import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, tick } from "@angular/core/testing";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { FormControl, FormGroup } from "@angular/forms";
import { FieldTypeConfig } from "@ngx-formly/core";
@@ -660,6 +660,162 @@ describe("HuggingFaceComponent (TestBed)", () => {
initComponent();
expect(() => component.ngOnDestroy()).not.toThrow();
});
+
+ it("should clear taskPollInterval on destroy", fakeAsync(() => {
+ // Create a second fixture so the first one is still fetching tasks when this one inits
+ invalidateHuggingFaceModelCache();
+ const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+ const component2 = fixture2.componentInstance;
+ const { field: field2 } = buildFieldWithFormGroup("text-generation");
+ component2.field = field2;
+ fixture2.detectChanges(); // triggers ngOnInit → loadTasks() (sets tasksFetchSubscription)
+ http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("<svg></svg>"));
+
+ // Now init our main component — tasks in flight, so it enters poll path
+ const { field } = buildFieldWithFormGroup("text-generation");
+ component.field = field;
+ fixture.detectChanges();
+ flushIconRequests();
+
+ // comp enters poll path for tasks — taskPollInterval is set
+ expect((component as any).taskPollInterval).not.toBeNull();
+
+ // Destroy while poll is still running
+ expect(() => component.ngOnDestroy()).not.toThrow();
+
+ // Clean up fixture2
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.match(req => req.url.startsWith(`${API}/huggingface/models`)).forEach(req => req.flush([]));
+ fixture2.destroy();
+ discardPeriodicTasks();
+ }));
+ });
+
+ // ── Cached error states ──
+
+ describe("cached error states", () => {
+ it("should show cached tasks error for a second component without re-fetching", () => {
+ const { field: field1 } = buildFieldWithFormGroup();
+ component.field = field1;
+ fixture.detectChanges();
+ flushIconRequests();
+
+ // comp1: tasks error, models succeed → models are cached
+ http.expectOne(`${API}/huggingface/tasks`).error(new ProgressEvent("error"));
+ http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush([]);
+
+ expect(component.tasksError).toBeTruthy();
+
+ const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+ const component2 = fixture2.componentInstance;
+ const { field: field2 } = buildFieldWithFormGroup();
+ component2.field = field2;
+ fixture2.detectChanges();
+ flushIconRequests();
+
+ // Tasks error cached (no new tasks request); models cached (no new model request)
+
+ expect(component2.tasksError).toBeTruthy();
+ expect(component2.taskOptions).toEqual(STATIC_TASK_OPTIONS);
+ expect(component2.tasksLoading).toBe(false);
+
+ fixture2.destroy();
+ });
+
+ it("should show cached model error for a second component without re-fetching models", () => {
+ const { field: field1 } = buildFieldWithFormGroup("text-generation");
+ component.field = field1;
+ fixture.detectChanges();
+ flushIconRequests();
+
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).error(new ProgressEvent("error"));
+
+ expect(component.errorMessage).toBeTruthy();
+
+ const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+ const component2 = fixture2.componentInstance;
+ const { field: field2 } = buildFieldWithFormGroup("text-generation");
+ component2.field = field2;
+ fixture2.detectChanges();
+ flushIconRequests();
+
+ // Tasks cached; model error cached — no new HTTP requests at all
+ expect(component2.errorMessage).toBeTruthy();
+ expect(component2.loading).toBe(false);
+ expect(component2.pagedModels.length).toBe(0);
+
+ fixture2.destroy();
+ });
+ });
+
+ // ── Polling ──
+
+ describe("polling", () => {
+ it("should enter model poll path (loading=true) when another instance is already fetching models", fakeAsync(() => {
+ // comp1 fetches tasks first, but leaves models in flight
+ const { field: field1 } = buildFieldWithFormGroup("text-generation");
+ component.field = field1;
+ fixture.detectChanges();
+ flushIconRequests();
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ // models still in flight for comp1
+
+ // comp2: tasks cached, but models are in-flight → enters model poll path
+ const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+ const component2 = fixture2.componentInstance;
+ const { field: field2 } = buildFieldWithFormGroup("text-generation");
+ component2.field = field2;
+ fixture2.detectChanges();
+ http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("<svg></svg>"));
+
+ expect(component2.loading).toBe(true); // poll path entered
+
+ // Clean up: flush comp1's pending model request, discard comp2's poll
+ http.expectOne(req => req.url.startsWith(`${API}/huggingface/models`)).flush(buildModels(3));
+ fixture2.destroy();
+ discardPeriodicTasks();
+ }));
+
+ it("should enter task poll path (tasksLoading=true) when another instance is already fetching tasks", fakeAsync(() => {
+ // comp1 starts fetching tasks — leave in flight
+ const { field: field1 } = buildFieldWithFormGroup("text-generation");
+ component.field = field1;
+ fixture.detectChanges();
+ flushIconRequests();
+ // tasks and models both in flight for comp1
+
+ // comp2: tasksFetchSubscription not null → enters task poll path
+ const fixture2 = TestBed.createComponent(HuggingFaceComponent);
+ const component2 = fixture2.componentInstance;
+ const { field: field2 } = buildFieldWithFormGroup("text-generation");
+ component2.field = field2;
+ fixture2.detectChanges();
+ http.match(req => req.url.startsWith("assets/")).forEach(req => req.flush("<svg></svg>"));
+
+ expect(component2.tasksLoading).toBe(true); // poll path entered
+
+ // Clean up: flush comp1's pending requests, discard comp2's polls
+ http.expectOne(`${API}/huggingface/tasks`).flush(buildTaskResponse());
+ http.match(req => req.url.startsWith(`${API}/huggingface/models`)).forEach(req => req.flush([]));
+ fixture2.destroy();
+ discardPeriodicTasks();
+ }));
+
+ it("should clear error and re-fetch models on retryLoad", () => {
+ initComponent();
+
+ component.onTaskSelected("image-classification");
+ http.expectOne(`${API}/huggingface/models?task=image-classification`).error(new ProgressEvent("error"));
+
+ expect(component.errorMessage).toBeTruthy();
+
+ component.retryLoad();
+ http.expectOne(`${API}/huggingface/models?task=image-classification`).flush(buildModels(4, "img"));
+
+ expect(component.errorMessage).toBeNull();
+ expect(component.pagedModels.length).toBe(4);
+ });
});
// ── Pagination edge cases ──
diff --git a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
index f84d4f940f..f634c66a2a 100644
--- a/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
+++ b/frontend/src/app/workspace/component/hugging-face/hugging-face.component.ts
@@ -252,15 +252,13 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
tasksFetchSubscription = this.http
.get<HuggingFaceTaskOption[]>(`${AppSettings.getApiEndpoint()}/huggingface/tasks`)
.pipe(
- takeUntil(this.destroy$),
finalize(() => {
- // If takeUntil fires before next/error, reset the module-level guard
- // so the next component instance can start a fresh fetch.
if (cachedTaskOptions === null && tasksFetchError === null) {
tasksFetchSubscription = null;
}
})
)
+ // eslint-disable-next-line rxjs-angular/prefer-takeuntil
.subscribe({
next: tasks => {
tasksFetchSubscription = null;
@@ -296,8 +294,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
this.restoreTaskState(tag);
this.searchText = "";
this.filteredModels = null;
- // Cancel any in-flight server search for the previous task
- this.searchSubject$.next("");
this.loadAllModels();
}
@@ -379,14 +375,8 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
{ observe: "response" }
)
.pipe(
- takeUntil(this.destroy$),
- finalize(() => {
- // If takeUntil cancels before next/error fires, clear the in-flight
- // guard so a later instance re-fetches instead of polling forever.
- if (!allModelsByTag.has(tag) && !errorByTag.has(tag)) {
- inFlightByTag.delete(tag);
- }
- })
+ finalize(() => inFlightByTag.delete(tag)),
+ takeUntil(this.destroy$)
)
.subscribe({
next: resp => {
@@ -395,7 +385,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
truncatedByTag.add(tag);
}
allModelsByTag.set(tag, models);
- inFlightByTag.delete(tag);
this.loading = false;
this.truncated = truncatedByTag.has(tag);
this.allModels = models;
@@ -405,7 +394,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
console.error(`Failed to load HuggingFace models for task '${tag}':`, err);
const msg = "Failed to load models. Click retry to try again.";
errorByTag.set(tag, msg);
- inFlightByTag.delete(tag);
this.loading = false;
this.errorMessage = msg;
this.cdr.detectChanges();
@@ -459,11 +447,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
.pipe(
debounceTime(300),
switchMap(query => {
- if (!query.trim()) {
- this.searchLoading = false;
- this.cdr.detectChanges();
- return of(null);
- }
const tag = this.selectedTaskTag || "text-generation";
this.searchLoading = true;
this.cdr.detectChanges();
@@ -497,8 +480,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
if (!query.trim()) {
this.filteredModels = null;
this.searchLoading = false;
- // Cancel any in-flight server search via switchMap
- this.searchSubject$.next("");
this.goToPage(0);
return;
}
@@ -517,8 +498,6 @@ export class HuggingFaceComponent extends FieldType<FieldTypeConfig> implements
this.searchText = "";
this.filteredModels = null;
this.searchLoading = false;
- // Cancel any in-flight server search via switchMap
- this.searchSubject$.next("");
this.goToPage(0);
}
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index 267b686b10..e23aa7ae70 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -1861,4 +1861,227 @@ describe("OperatorPropertyEditFrameComponent", () => {
expect(component.formData.marker).toBe("allowed");
});
});
+ describe("real template rendering", () => {
+ let realFixture: ComponentFixture<OperatorPropertyEditFrameComponent>;
+ let realComponent: OperatorPropertyEditFrameComponent;
+
+ beforeEach(async () => {
+ TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ providers: [
+ WorkflowActionService,
+ { provide: OperatorMetadataService, useClass: StubOperatorMetadataService },
+ { provide: ComputingUnitStatusService, useClass: MockComputingUnitStatusService },
+ DatePipe,
+ ...commonTestProviders,
+ ],
+ imports: [
+ OperatorPropertyEditFrameComponent,
+ BrowserAnimationsModule,
+ FormsModule,
+ FormlyModule.forRoot(TEXERA_FORMLY_CONFIG),
+ FormlyNgZorroAntdModule,
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ ],
+ }).compileComponents();
+
+ realFixture = TestBed.createComponent(OperatorPropertyEditFrameComponent);
+ realComponent = realFixture.componentInstance;
+ });
+
+ it("should render the title section when editingTitle is false and formTitle is set", () => {
+ realComponent.editingTitle = false;
+ realComponent.formTitle = "My Operator";
+ realFixture.detectChanges();
+ const titleEl = realFixture.debugElement.query(By.css("#formly-title"));
+ expect(titleEl).toBeTruthy();
+ const h3 = realFixture.debugElement.query(By.css("h3.texera-workspace-property-editor-title"));
+ expect(h3).toBeTruthy();
+ expect((h3.nativeElement as HTMLElement).textContent?.trim()).toBe("My Operator");
+ });
+
+ it("should not render the h3 title when formTitle is undefined", () => {
+ realComponent.editingTitle = false;
+ realComponent.formTitle = undefined;
+ realFixture.detectChanges();
+ expect(realFixture.debugElement.query(By.css("h3.texera-workspace-property-editor-title"))).toBeNull();
+ });
+
+ it("should disable the edit button when interactive is false", () => {
+ realComponent.editingTitle = false;
+ realComponent.interactive = false;
+ realFixture.detectChanges();
+ const btn = realFixture.debugElement.query(By.css("#formly-title button")).nativeElement as HTMLButtonElement;
+ expect(btn.disabled).toBe(true);
+ });
+
+ it("should enable the edit button when interactive is true", () => {
+ realComponent.editingTitle = false;
+ realComponent.interactive = true;
+ realFixture.detectChanges();
+ const btn = realFixture.debugElement.query(By.css("#formly-title button")).nativeElement as HTMLButtonElement;
+ expect(btn.disabled).toBe(false);
+ });
+
+ it("should hide the title section when editingTitle is true", () => {
+ realComponent.editingTitle = true;
+ realFixture.detectChanges();
+ expect(realFixture.debugElement.query(By.css("#formly-title"))).toBeNull();
+ });
+
+ it("should hide the customName div when editingTitle is false", () => {
+ realComponent.editingTitle = false;
+ realFixture.detectChanges();
+ const customName = realFixture.debugElement.query(By.css("#customName"));
+ expect(customName).toBeTruthy();
+ expect((customName.nativeElement as HTMLElement).hidden).toBe(true);
+ });
+
+ it("should show the customName div when editingTitle is true", () => {
+ realComponent.editingTitle = true;
+ realFixture.detectChanges();
+ const customName = realFixture.debugElement.query(By.css("#customName"));
+ expect(customName).toBeTruthy();
+ expect((customName.nativeElement as HTMLElement).hidden).toBe(false);
+ });
+
+ it("should show the PythonLambdaFunction icon when currentOperatorId includes PythonLambdaFunction", () => {
+ realComponent.editingTitle = false;
+ realComponent.currentOperatorId = "PythonLambdaFunction-abc";
+ realFixture.detectChanges();
+ expect(realFixture.debugElement.query(By.css(".question-circle-button"))).toBeTruthy();
+ });
+
+ it("should not show the PythonLambdaFunction icon for other operator types", () => {
+ realComponent.editingTitle = false;
+ realComponent.currentOperatorId = "ScanSource-abc";
+ realFixture.detectChanges();
+ expect(realFixture.debugElement.query(By.css(".question-circle-button"))).toBeNull();
+ });
+
+ it("should not render the form section when formlyFields is undefined", () => {
+ realFixture.detectChanges();
+ expect(realFixture.debugElement.query(By.css(".property-editor-form"))).toBeNull();
+ });
+
+ it("should render the operator version span", () => {
+ realComponent.operatorVersion = "v1.2.3";
+ realFixture.detectChanges();
+ const versionEl = realFixture.debugElement.query(By.css(".operator-version span"));
+ expect(versionEl).toBeTruthy();
+ expect((versionEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Operator Version: v1.2.3");
+ });
+
+ // ── HF task preview template nodes ──
+ // formlyFields=[] and formlyFormGroup=new FormGroup({}) satisfy the outer
+ // *ngIf without triggering Formly rendering; the getter is mocked directly.
+
+ function setupPreview(preview: object): void {
+ vi.spyOn(realComponent, "huggingFaceTaskPreview", "get").mockReturnValue(preview as any);
+ realComponent.formlyFields = [];
+ realComponent.formlyFormGroup = new FormGroup({});
+ realComponent.formData = {};
+ realFixture.detectChanges();
+ }
+
+ it("should not render the HF preview card when huggingFaceTaskPreview is null", () => {
+ vi.spyOn(realComponent, "huggingFaceTaskPreview", "get").mockReturnValue(null);
+ realComponent.formlyFields = [];
+ realComponent.formlyFormGroup = new FormGroup({});
+ realComponent.formData = {};
+ realFixture.detectChanges();
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview"))).toBeNull();
+ });
+
+ it("should render a video element for kind='video'", () => {
+ setupPreview({ kind: "video", title: "Video preview", assetSrc: "assets/sample.mp4" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview"))).toBeTruthy();
+ expect(realFixture.debugElement.query(By.css("video.hf-task-preview-media"))).toBeTruthy();
+ });
+
+ it("should bind [src] and [muted] on the video element", () => {
+ setupPreview({ kind: "video", title: "Video preview", assetSrc: "assets/sample.mp4" });
+ const video = realFixture.debugElement.query(By.css("video")).nativeElement as HTMLVideoElement;
+ expect(video.muted).toBe(true);
+ });
+
+ it("should render an img element for kind='image'", () => {
+ setupPreview({ kind: "image", title: "Image preview", assetSrc: "assets/sample.png" });
+ expect(realFixture.debugElement.query(By.css("img.hf-task-preview-media"))).toBeTruthy();
+ });
+
+ it("should render an audio element for kind='audio'", () => {
+ setupPreview({ kind: "audio", title: "Audio preview", assetSrc: "assets/sample.mp3" });
+ expect(realFixture.debugElement.query(By.css("audio.hf-task-preview-audio"))).toBeTruthy();
+ });
+
+ it("should render the text surface for kind='text'", () => {
+ setupPreview({ kind: "text", title: "Text preview", body: "Some body" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-surface"))).toBeTruthy();
+ const titleEl = realFixture.debugElement.query(By.css(".hf-task-preview-text-title"));
+ expect((titleEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Text preview");
+ const bodyEl = realFixture.debugElement.query(By.css(".hf-task-preview-text-body"));
+ expect((bodyEl.nativeElement as HTMLElement).textContent?.trim()).toBe("Some body");
+ });
+
+ it("should render outputBody in the text surface when present", () => {
+ setupPreview({ kind: "text", title: "T", body: "B", outputBody: "Output" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-output"))).toBeTruthy();
+ });
+
+ it("should not render outputBody in the text surface when absent", () => {
+ setupPreview({ kind: "text", title: "T", body: "B" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-text-output"))).toBeNull();
+ });
+
+ it("should render the flow section when inputLabel and outputLabel are set", () => {
+ setupPreview({ kind: "image", title: "T", inputLabel: "Input", outputLabel: "Output" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-flow"))).toBeTruthy();
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-arrow"))).toBeTruthy();
+ const chips = realFixture.debugElement.queryAll(By.css(".hf-task-preview-chip"));
+ expect(chips.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("should not render the flow section when inputLabel and outputLabel are absent", () => {
+ setupPreview({ kind: "image", title: "T" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-flow"))).toBeNull();
+ });
+
+ it("should render the description for non-text kinds with body", () => {
+ setupPreview({ kind: "image", title: "T", body: "Some description" });
+ const desc = realFixture.debugElement.query(By.css(".hf-task-preview-description"));
+ expect(desc).toBeTruthy();
+ expect((desc.nativeElement as HTMLElement).textContent?.trim()).toBe("Some description");
+ });
+
+ it("should not render the description for kind='text'", () => {
+ setupPreview({ kind: "text", title: "T", body: "B" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-description"))).toBeNull();
+ });
+
+ it("should render outputBody in meta for non-text kinds", () => {
+ setupPreview({ kind: "image", title: "T", outputBody: "Result" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-output"))).toBeTruthy();
+ });
+
+ it("should not render outputBody in meta for kind='text'", () => {
+ setupPreview({ kind: "text", title: "T", body: "B", outputBody: "Result" });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-output"))).toBeNull();
+ });
+
+ it("should render pills when pills array is non-empty", () => {
+ setupPreview({ kind: "text", title: "T", pills: ["NLP", "Classification"] });
+ const pillsContainer = realFixture.debugElement.query(By.css(".hf-task-preview-pills"));
+ expect(pillsContainer).toBeTruthy();
+ const pills = realFixture.debugElement.queryAll(By.css(".hf-task-preview-pill"));
+ expect(pills.length).toBe(2);
+ expect((pills[0].nativeElement as HTMLElement).textContent?.trim()).toBe("NLP");
+ });
+
+ it("should not render pills when pills array is empty", () => {
+ setupPreview({ kind: "text", title: "T", pills: [] });
+ expect(realFixture.debugElement.query(By.css(".hf-task-preview-pills"))).toBeNull();
+ });
+ });
});
diff --git a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index 40c74a60c2..128f674645 100644
--- a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++ b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -917,6 +917,10 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
message: () => "Upload an image or select an Input Image Column for this task.",
},
};
+ mappedField.validation = {
+ ...mappedField.validation,
+ show: true,
+ };
}
if (hfKey === "audioInput") {
mappedField.type = "huggingface-audio-upload";
@@ -945,6 +949,10 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
message: () => "Upload audio or select an Input Audio Column for this task.",
},
};
+ mappedField.validation = {
+ ...mappedField.validation,
+ show: true,
+ };
}
if (hfKey === "inputImageColumn") {
mappedField.expressions = {
@@ -986,6 +994,10 @@ export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, On
message: () => "Select a prompt column for this task.",
},
};
+ mappedField.validation = {
+ ...mappedField.validation,
+ show: true,
+ };
}
if (["systemPrompt", "maxNewTokens", "temperature"].includes(hfKey)) {
mappedField.expressions = {
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html
index 5f8fbe4674..71e4533591 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.html
@@ -18,7 +18,56 @@
-->
<div class="modal-body content-body">
- <ngx-json-viewer
- [expanded]="false"
- [json]="currentDisplayRowData"></ngx-json-viewer>
+ <div class="modal-toolbar">
+ <button
+ nz-button
+ nzType="default"
+ type="button"
+ (click)="copyText(prettyRowJson)">
+ Copy Row JSON
+ </button>
+ </div>
+
+ <div class="row-detail-list">
+ <div
+ *ngFor="let entry of rowEntries; trackBy: trackByEntryKey"
+ class="row-detail-item">
+ <div class="row-detail-header">
+ <div class="row-detail-key">{{ entry.key }}</div>
+ <button
+ nz-button
+ nzType="link"
+ type="button"
+ (click)="copyText(entry.value)">
+ Copy
+ </button>
+ </div>
+
+ <video
+ *ngIf="entry.isVideo; else checkAudio"
+ controls
+ [src]="entry.mediaSrc"
+ style="max-width: 100%; max-height: 480px; display: block; border: 1px solid #eee; border-radius: 4px"></video>
+
+ <ng-template #checkAudio>
+ <audio
+ *ngIf="entry.isAudio; else checkImage"
+ controls
+ [src]="entry.mediaSrc"
+ style="width: 100%; max-width: 420px; display: block"></audio>
+ </ng-template>
+
+ <ng-template #checkImage>
+ <img
+ *ngIf="entry.isImage; else textValue"
+ [src]="entry.mediaSrc"
+ alt="image result"
+ style="max-width: 100%; max-height: 480px; display: block; border: 1px solid #eee; border-radius: 4px" />
+
+ <ng-template #textValue>
+ <pre class="row-detail-pre row-detail-value">{{ entry.value }}</pre>
+ </ng-template>
+ </ng-template>
+ </div>
+ </div>
</div>
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts
index 8d7e1cc8f9..f82bd871c3 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.spec.ts
@@ -18,15 +18,20 @@
*/
import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
import { RowModalComponent } from "./result-panel-modal.component";
import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service";
import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal";
+import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { of } from "rxjs";
+import { AppSettings } from "../../../common/app-setting";
+import { NotificationService } from "../../../common/service/notification/notification.service";
describe("RowModalComponent", () => {
let component: RowModalComponent;
let fixture: ComponentFixture<RowModalComponent>;
+ let httpMock: HttpTestingController;
const mockTupleResult = { tuple: { id: "123", value: "test_data" } };
const workflowResultServiceSpy = {
@@ -41,7 +46,7 @@ describe("RowModalComponent", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
- imports: [RowModalComponent],
+ imports: [RowModalComponent, HttpClientTestingModule],
providers: [
{ provide: NZ_MODAL_DATA, useValue: { operatorId: "op-1", rowIndex: 3 } },
{ provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } },
@@ -49,6 +54,12 @@ describe("RowModalComponent", () => {
{ provide: PanelResizeService, useValue: resizeServiceSpy },
],
}).compileComponents();
+
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
});
beforeEach(() => {
@@ -65,4 +76,224 @@ describe("RowModalComponent", () => {
component.ngOnChanges();
expect(component.currentDisplayRowData).toEqual(mockTupleResult.tuple);
});
+
+ it("should use data URL directly without fetching for base64 media", () => {
+ const dataUrl = "data:image/png;base64,abc123";
+ (component as any).buildRowEntries({ img: dataUrl });
+ httpMock.expectNone(`${AppSettings.getApiEndpoint()}/huggingface/media-proxy`);
+ const entry = (component as any).buildRowEntries({ img: dataUrl })[0];
+ expect(entry.mediaSrc).toBe(dataUrl);
+ expect(entry.isImage).toBe(true);
+ });
+
+ it("should fetch remote image URL via media-proxy and set blob URL on success", () => {
+ const createObjectURLSpy = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:fake-url");
+ const remoteUrl = "https://example.com/photo.png";
+ const entries = (component as any).buildRowEntries({ img: remoteUrl });
+ const entry = entries[0];
+
+ expect(entry.mediaSrc).toBe("");
+ expect(entry.isImage).toBe(true);
+
+ const req = httpMock.expectOne(
+ `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`
+ );
+ req.flush(new Blob(["fake"], { type: "image/png" }));
+
+ expect(createObjectURLSpy).toHaveBeenCalled();
+ expect(entry.mediaSrc).toBe("blob:fake-url");
+ createObjectURLSpy.mockRestore();
+ });
+
+ it("should fall back to the text view (not the raw remote URL) when media-proxy request fails", () => {
+ const remoteUrl = "https://example.com/clip.mp4";
+ const entries = (component as any).buildRowEntries({ vid: remoteUrl });
+ const entry = entries[0];
+ expect(entry.isVideo).toBe(true);
+
+ const req = httpMock.expectOne(
+ `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`
+ );
+ req.error(new ProgressEvent("error"));
+
+ expect(entry.mediaSrc).toBe("");
+ expect(entry.isVideo).toBe(false);
+ expect(entry.isImage).toBe(false);
+ expect(entry.isAudio).toBe(false);
+ });
+
+ it("should not fetch media-proxy for non-media remote URLs", () => {
+ const remoteUrl = "https://example.com/some-text-value";
+ (component as any).buildRowEntries({ text: remoteUrl });
+ httpMock.expectNone(`${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`);
+ });
+
+ it("should revoke blob URLs on destroy", () => {
+ const revokeSpy = vi.spyOn(URL, "revokeObjectURL");
+ (component as any).allocatedBlobUrls.push("blob:url-1", "blob:url-2");
+ component.ngOnDestroy();
+ expect(revokeSpy).toHaveBeenCalledWith("blob:url-1");
+ expect(revokeSpy).toHaveBeenCalledWith("blob:url-2");
+ revokeSpy.mockRestore();
+ });
+
+ it("prettyRowJson should return pretty-printed JSON of currentDisplayRowData", () => {
+ component.currentDisplayRowData = { name: "test", value: 42 };
+ expect(component.prettyRowJson).toBe(JSON.stringify({ name: "test", value: 42 }, null, 2));
+ });
+
+ it("trackByEntryKey should return the entry key", () => {
+ expect(component.trackByEntryKey(0, { key: "myKey" })).toBe("myKey");
+ expect(component.trackByEntryKey(5, { key: "another" })).toBe("another");
+ });
+
+ it("should JSON-stringify non-string values in buildRowEntries", () => {
+ const entries = (component as any).buildRowEntries({ count: 42, arr: [1, 2, 3] });
+ expect(entries[0].value).toBe("42");
+ expect(entries[0].isImage).toBe(false);
+ expect(entries[0].isVideo).toBe(false);
+ expect(entries[0].isAudio).toBe(false);
+ expect(entries[1].value).toBe("[1,2,3]");
+ expect(entries[1].mediaSrc).toBe("[1,2,3]");
+ });
+
+ it("should not update row data when tuple is null", () => {
+ vi.spyOn(workflowResultServiceSpy, "getPaginatedResultService").mockReturnValueOnce({
+ selectTuple: vi.fn().mockReturnValue(of({ tuple: null })),
+ } as any);
+ component.currentDisplayRowData = { existing: "data" };
+ component.ngOnChanges();
+ expect(component.currentDisplayRowData).toEqual({ existing: "data" });
+ });
+
+ it("should call notificationService.success on successful clipboard copy", async () => {
+ const notifService = TestBed.inject(NotificationService);
+ const successSpy = vi.spyOn(notifService, "success").mockImplementation(() => undefined as any);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText: vi.fn().mockResolvedValue(undefined) },
+ configurable: true,
+ writable: true,
+ });
+ component.copyText("hello world");
+ await new Promise(r => setTimeout(r, 0));
+ expect(successSpy).toHaveBeenCalledWith("Copied to clipboard");
+ successSpy.mockRestore();
+ });
+
+ it("should call notificationService.error on clipboard copy failure", async () => {
+ const notifService = TestBed.inject(NotificationService);
+ const errorSpy = vi.spyOn(notifService, "error").mockImplementation(() => undefined as any);
+ Object.defineProperty(navigator, "clipboard", {
+ value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
+ configurable: true,
+ writable: true,
+ });
+ component.copyText("hello world");
+ await new Promise(r => setTimeout(r, 0));
+ expect(errorSpy).toHaveBeenCalledWith("Failed to copy");
+ errorSpy.mockRestore();
+ });
+});
+
+describe("RowModalComponent (with pre-loaded rowData)", () => {
+ let component: RowModalComponent;
+ let httpMock: HttpTestingController;
+
+ const rowData = { id: "123", imgUrl: "data:image/png;base64,abc" };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [RowModalComponent, HttpClientTestingModule],
+ providers: [
+ { provide: NZ_MODAL_DATA, useValue: { operatorId: "op-2", rowIndex: 0, rowData } },
+ { provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } },
+ {
+ provide: WorkflowResultService,
+ useValue: { getPaginatedResultService: vi.fn().mockReturnValue(null) },
+ },
+ { provide: PanelResizeService, useValue: { pageSize: 10 } },
+ ],
+ }).compileComponents();
+
+ httpMock = TestBed.inject(HttpTestingController);
+ const fixture = TestBed.createComponent(RowModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it("should initialize currentDisplayRowData from rowData in modal data", () => {
+ expect(component.currentDisplayRowData).toEqual(rowData);
+ });
+
+ it("should build rowEntries immediately for data URL images in rowData", () => {
+ const imageEntry = component.rowEntries.find(e => e.key === "imgUrl");
+ expect(imageEntry?.isImage).toBe(true);
+ expect(imageEntry?.mediaSrc).toBe(rowData.imgUrl);
+ });
+
+ it("should build rowEntries for all fields in rowData", () => {
+ expect(component.rowEntries.length).toBe(2);
+ const idEntry = component.rowEntries.find(e => e.key === "id");
+ expect(idEntry?.value).toBe("123");
+ });
+});
+
+describe("RowModalComponent (template rendering)", () => {
+ let component: RowModalComponent;
+ let fixture: ComponentFixture<RowModalComponent>;
+ let httpMock: HttpTestingController;
+
+ const rowData = {
+ video: "data:video/mp4;base64,vid123",
+ image: "data:image/png;base64,img123",
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [RowModalComponent, HttpClientTestingModule],
+ providers: [
+ { provide: NZ_MODAL_DATA, useValue: { operatorId: "op-3", rowIndex: 0, rowData } },
+ { provide: NzModalRef, useValue: { getConfig: () => ({}), close: vi.fn() } },
+ {
+ provide: WorkflowResultService,
+ useValue: { getPaginatedResultService: vi.fn().mockReturnValue(null) },
+ },
+ { provide: PanelResizeService, useValue: { pageSize: 10 } },
+ ],
+ }).compileComponents();
+
+ httpMock = TestBed.inject(HttpTestingController);
+ fixture = TestBed.createComponent(RowModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it("should bind the video element's src to the video entry's mediaSrc", () => {
+ const videoEl = fixture.debugElement.query(By.css("video")).nativeElement as HTMLVideoElement;
+ expect(videoEl.src).toBe(rowData.video);
+ });
+
+ it("should bind the image element's src to the image entry's mediaSrc", () => {
+ const imgEl = fixture.debugElement.query(By.css("img")).nativeElement as HTMLImageElement;
+ expect(imgEl.src).toBe(rowData.image);
+ });
+
+ it("should call copyText with the entry's value when its Copy button is clicked", () => {
+ const copyTextSpy = vi.spyOn(component, "copyText").mockImplementation(() => undefined);
+ const copyButtons = fixture.debugElement.queryAll(By.css(".row-detail-header button"));
+ const videoEntryButton = copyButtons[0];
+
+ videoEntryButton.triggerEventHandler("click", null);
+
+ expect(copyTextSpy).toHaveBeenCalledWith(rowData.video);
+ copyTextSpy.mockRestore();
+ });
});
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts
index 278a01dd5a..dddb30caf2 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-modal.component.ts
@@ -17,12 +17,18 @@
* under the License.
*/
-import { Component, inject, OnChanges } from "@angular/core";
+import { Component, inject, OnChanges, OnDestroy } from "@angular/core";
+import { CommonModule } from "@angular/common";
import { NZ_MODAL_DATA, NzModalRef } from "ng-zorro-antd/modal";
+import { NzButtonModule } from "ng-zorro-antd/button";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { HttpClient } from "@angular/common/http";
import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { PanelResizeService } from "../../service/workflow-result/panel-resize/panel-resize.service";
-import { NgxJsonViewerModule } from "ngx-json-viewer";
+import { NotificationService } from "../../../common/service/notification/notification.service";
+import { isAudioUrl, isVideoUrl, isImageUrl } from "src/app/common/util/media-type.util";
+import { AppSettings } from "../../../common/app-setting";
/**
*
@@ -42,29 +48,110 @@ import { NgxJsonViewerModule } from "ngx-json-viewer";
selector: "texera-row-modal-content",
templateUrl: "./result-panel-modal.component.html",
styleUrls: ["./result-panel-model.component.scss"],
- imports: [NgxJsonViewerModule],
+ imports: [CommonModule, NzButtonModule, NzIconModule],
})
-export class RowModalComponent implements OnChanges {
+export class RowModalComponent implements OnChanges, OnDestroy {
+ rowEntries: { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] =
+ [];
+ private readonly allocatedBlobUrls: string[] = [];
// Index of current displayed row in currentResult
- readonly operatorId: string = inject(NZ_MODAL_DATA).operatorId;
- rowIndex: number = inject(NZ_MODAL_DATA).rowIndex;
+ private readonly modalData: { operatorId: string; rowIndex: number; rowData?: Record<string, unknown> } =
+ inject(NZ_MODAL_DATA);
+ readonly operatorId: string = this.modalData.operatorId;
+ rowIndex: number = this.modalData.rowIndex;
currentDisplayRowData: Record<string, unknown> = {};
constructor(
public modal: NzModalRef<any, number>,
+ private http: HttpClient,
private workflowResultService: WorkflowResultService,
- private resizeService: PanelResizeService
+ private resizeService: PanelResizeService,
+ private notificationService: NotificationService
) {
+ if (this.modalData.rowData) {
+ this.currentDisplayRowData = this.modalData.rowData;
+ this.rowEntries = this.buildRowEntries(this.currentDisplayRowData);
+ }
this.ngOnChanges();
}
+ get prettyRowJson(): string {
+ return JSON.stringify(this.currentDisplayRowData, null, 2);
+ }
+
+ copyText(text: string): void {
+ navigator.clipboard.writeText(text).then(
+ () => this.notificationService.success("Copied to clipboard"),
+ () => this.notificationService.error("Failed to copy")
+ );
+ }
+
ngOnChanges(): void {
this.workflowResultService
.getPaginatedResultService(this.operatorId)
?.selectTuple(this.rowIndex, this.resizeService.pageSize)
.pipe(untilDestroyed(this))
.subscribe(res => {
- this.currentDisplayRowData = res.tuple;
+ if (res?.tuple) {
+ this.currentDisplayRowData = res.tuple;
+ this.rowEntries = this.buildRowEntries(this.currentDisplayRowData);
+ }
+ });
+ }
+
+ trackByEntryKey(_index: number, entry: { key: string }): string {
+ return entry.key;
+ }
+
+ ngOnDestroy(): void {
+ for (const url of this.allocatedBlobUrls) {
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ private fetchBlobSrc(
+ entry: { mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean },
+ remoteUrl: string
+ ): void {
+ const proxyUrl = `${AppSettings.getApiEndpoint()}/huggingface/media-proxy?url=${encodeURIComponent(remoteUrl)}`;
+ this.http
+ .get(proxyUrl, { responseType: "blob" })
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: blob => {
+ const blobUrl = URL.createObjectURL(blob);
+ this.allocatedBlobUrls.push(blobUrl);
+ entry.mediaSrc = blobUrl;
+ },
+ error: () => {
+ // The proxy rejected this URL (e.g. the SSRF allowlist blocked it). Don't fall
+ // back to loading the raw remote URL directly in the browser — fall back to the
+ // text view instead so the entry never bypasses the proxy's allowlist.
+ entry.isVideo = false;
+ entry.isImage = false;
+ entry.isAudio = false;
+ },
});
}
+
+ private buildRowEntries(
+ rowData: Record<string, unknown>
+ ): { key: string; value: string; mediaSrc: string; isVideo: boolean; isImage: boolean; isAudio: boolean }[] {
+ return Object.entries(rowData).map(([key, val]) => {
+ const value = typeof val === "string" ? val : JSON.stringify(val) ?? String(val);
+ const isRemote = value.startsWith("http://") || value.startsWith("https://");
+ const entry = {
+ key,
+ value,
+ mediaSrc: isRemote ? "" : value,
+ isVideo: typeof val === "string" && isVideoUrl(val),
+ isImage: typeof val === "string" && isImageUrl(val),
+ isAudio: typeof val === "string" && isAudioUrl(val),
+ };
+ if (isRemote && (entry.isVideo || entry.isImage || entry.isAudio)) {
+ this.fetchBlobSrc(entry, value);
+ }
+ return entry;
+ });
+ }
}
diff --git a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss b/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
index eb5ff4dece..aafefae2d3 100644
--- a/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
+++ b/frontend/src/app/workspace/component/result-panel/result-panel-model.component.scss
@@ -23,3 +23,54 @@
height: 100%;
width: 100%;
}
+
+.modal-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 12px;
+}
+
+.row-detail-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.row-detail-item {
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ padding: 12px;
+ background: #fff;
+}
+
+.row-detail-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.row-detail-key {
+ font-weight: 600;
+ word-break: break-word;
+}
+
+.row-detail-pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: monospace;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.row-detail-value {
+ max-height: none;
+ overflow: visible;
+ padding: 8px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ background: #fafafa;
+ user-select: text;
+}
diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html
index 5400d978ee..e9c5f83ee4 100644
--- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html
+++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.html
@@ -161,7 +161,38 @@
class="table-cell"
nzEllipsis
(click)="open(i, row)">
- <span class="cell-content">{{ column.getCell(row) }}</span>
+ <span class="cell-content">
+ <ng-container [ngSwitch]="getCellMediaType(row, columnIndex)">
+ <ng-container *ngSwitchCase="'video'">
+ <i
+ nz-icon
+ nzType="play-circle"
+ nzTheme="outline"
+ title="Play video"
+ style="margin-right: 4px"></i>
+ Play Video
+ </ng-container>
+ <ng-container *ngSwitchCase="'audio'">
+ <i
+ nz-icon
+ nzType="sound"
+ nzTheme="outline"
+ title="Play audio"
+ style="margin-right: 4px"></i>
+ Play Audio
+ </ng-container>
+ <ng-container *ngSwitchCase="'image'">
+ <i
+ nz-icon
+ nzType="eye"
+ nzTheme="outline"
+ title="View image"
+ style="margin-right: 4px"></i>
+ View Image
+ </ng-container>
+ <ng-container *ngSwitchDefault>{{ column.getCell(row) }}</ng-container>
+ </ng-container>
+ </span>
<button
(click)="downloadData(currentResult[i][column.columnDef], i, columnIndex, column.columnDef); $event.stopPropagation()"
nz-button
diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts
index 5171c82d3c..0265490ac1 100644
--- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.spec.ts
@@ -31,6 +31,7 @@ import { By, DomSanitizer } from "@angular/platform-browser";
import { of, Subject } from "rxjs";
import { commonTestProviders } from "../../../../common/testing/test-utils";
import { GuiConfigService } from "../../../../common/service/gui-config.service";
+import { isAudioUrl, isImageUrl, isVideoUrl } from "../../../../common/util/media-type.util";
import {
OperatorPaginationResultService,
WorkflowResultService,
@@ -647,4 +648,72 @@ describe("ResultTableFrameComponent", () => {
expect(downloadSpy).toHaveBeenCalledWith("alice", 0, 0, "name");
});
});
+
+ it("should detect media URLs for result cells", () => {
+ expect(component.isVideoCell("https://example.com/clip.mp4")).toBe(true);
+ expect(component.isAudioCell("https://example.com/sound.wav")).toBe(true);
+ expect(component.isImageCell("data:image/png;base64,AAAA")).toBe(true);
+ });
+
+ it("should reject non-media values for result cells", () => {
+ expect(component.isVideoCell("plain text")).toBe(false);
+ expect(component.isAudioCell(123 as unknown)).toBe(false);
+ expect(component.isImageCell(null as unknown)).toBe(false);
+ });
+
+ it("media-type util helpers should classify URLs consistently", () => {
+ expect(isVideoUrl("https://example.com/clip.webm")).toBe(true);
+ expect(isAudioUrl("https://example.com/track.flac")).toBe(true);
+ expect(isImageUrl("https://example.com/image.webp")).toBe(true);
+ expect(isVideoUrl("text")).toBe(false);
+ expect(isAudioUrl("text")).toBe(false);
+ expect(isImageUrl("text")).toBe(false);
+ });
+
+ describe("media cell rendering in table", () => {
+ beforeEach(() => {
+ component.operatorId = "test-op";
+ });
+
+ it("should render Play Video indicator for video URL cells", () => {
+ (component as any).setupResultTable([{ media: "https://example.com/clip.mp4" }], 1);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.textContent).toContain("Play Video");
+ });
+
+ it("should render Play Audio indicator for audio URL cells", () => {
+ (component as any).setupResultTable([{ media: "https://example.com/clip.mp3" }], 1);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.textContent).toContain("Play Audio");
+ });
+
+ it("should render View Image indicator for image URL cells", () => {
+ (component as any).setupResultTable([{ media: "https://example.com/photo.jpg" }], 1);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.textContent).toContain("View Image");
+ });
+
+ it("should render plain text for non-media cell values", () => {
+ (component as any).setupResultTable([{ label: "just text" }], 1);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.textContent).toContain("just text");
+ });
+
+ it("should render column headers matching the row keys", () => {
+ (component as any).setupResultTable([{ score: "0.95", url: "https://example.com/a.png" }], 1);
+ fixture.detectChanges();
+
+ const el = fixture.nativeElement as HTMLElement;
+ expect(el.textContent).toContain("score");
+ expect(el.textContent).toContain("url");
+ });
+ });
});
diff --git a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts
index 72a0dbbf72..e46fa49f51 100644
--- a/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts
+++ b/frontend/src/app/workspace/component/result-panel/result-table-frame/result-table-frame.component.ts
@@ -37,10 +37,11 @@ import { IndexableObject, TableColumn } from "../../../types/result-table.interf
import { RowModalComponent } from "../result-panel-modal.component";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
+import { isAudioUrl, isVideoUrl, isImageUrl } from "../../../../common/util/media-type.util";
import { ResultExportationComponent } from "../../result-exportation/result-exportation.component";
import { WorkflowStatusService } from "../../../service/workflow-status/workflow-status.service";
import { GuiConfigService } from "../../../../common/service/gui-config.service";
-import { NgIf, NgFor, NgClass } from "@angular/common";
+import { NgIf, NgFor, NgClass, NgSwitch, NgSwitchCase, NgSwitchDefault } from "@angular/common";
import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
import { NzInputDirective } from "ng-zorro-antd/input";
import { NzButtonComponent } from "ng-zorro-antd/button";
@@ -48,6 +49,8 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave";
import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
import { NzIconDirective } from "ng-zorro-antd/icon";
+export type MediaCellType = "video" | "audio" | "image" | "text";
+
/**
* The Component will display the result in an excel table format,
* where each row represents a result from the workflow,
@@ -63,6 +66,9 @@ import { NzIconDirective } from "ng-zorro-antd/icon";
styleUrls: ["./result-table-frame.component.scss"],
imports: [
NgIf,
+ NgSwitch,
+ NgSwitchCase,
+ NgSwitchDefault,
NzSpaceCompactItemDirective,
NzInputDirective,
NzButtonComponent,
@@ -109,6 +115,13 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
widthPercent: string = "";
isOperatorFinished: boolean = false;
+ // Media type of each cell, precomputed once per row when result data arrives so the
+ // template doesn't re-run getCell + regex-based classification on every change
+ // detection cycle. Keyed by row object (not row index) because *ngFor iterates
+ // basicTable.data, which under front-end pagination is a page-local slice of
+ // currentResult whose indices don't line up with currentResult's indices.
+ cellMediaTypes: Map<IndexableObject, MediaCellType[]> = new Map();
+
constructor(
private modalService: NzModalService,
private workflowActionService: WorkflowActionService,
@@ -431,6 +444,10 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
// generate columnDef from first row, column definition is in order
this.currentColumns = this.generateColumns(columns);
this.totalNumTuples = totalRowCount;
+
+ this.cellMediaTypes = new Map(
+ this.currentResult.map(row => [row, this.currentColumns!.map(column => this.classifyCell(column.getCell(row)))])
+ );
}
/**
@@ -477,6 +494,31 @@ export class ResultTableFrameComponent implements OnInit, OnChanges {
}
}
+ isVideoCell(value: unknown): boolean {
+ return typeof value === "string" && isVideoUrl(value);
+ }
+
+ isAudioCell(value: unknown): boolean {
+ return typeof value === "string" && isAudioUrl(value);
+ }
+
+ isImageCell(value: unknown): boolean {
+ return typeof value === "string" && isImageUrl(value);
+ }
+
+ private classifyCell(value: unknown): MediaCellType {
+ if (this.isVideoCell(value)) return "video";
+ if (this.isAudioCell(value)) return "audio";
+ if (this.isImageCell(value)) return "image";
+ return "text";
+ }
+
+ // O(1) lookup into the precomputed cellMediaTypes map, used by the template
+ // instead of calling isVideoCell/isAudioCell/isImageCell on every change detection cycle.
+ getCellMediaType(row: IndexableObject, columnIndex: number): MediaCellType {
+ return this.cellMediaTypes.get(row)?.[columnIndex] ?? "text";
+ }
+
onColumnSearch(event: Event): void {
const input = event.target as HTMLInputElement;
this.columnSearch = input.value;