Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions frontend/proxy.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
"secure": false,
"changeOrigin": true
},
<<<<<<< HEAD
"/api/models": {
=======
"/api/notebook-migration": {
"target": "http://localhost:9098",
"secure": false,
"changeOrigin": true
},
"/api/models": {
>>>>>>> 1facefb18 (fix(frontend): render workflow covers on the hub landing page (#8383))
"target": "http://localhost:9096",
"secure": false,
"changeOrigin": true
Expand All @@ -35,6 +44,14 @@
"secure": false,
"changeOrigin": true
},
<<<<<<< HEAD
=======
"/api/access/model/**": {
"target": "http://localhost:9092",
"secure": false,
"changeOrigin": true
},
>>>>>>> 1facefb18 (fix(frontend): render workflow covers on the hub landing page (#8383))
"/api/access/computing-unit/**": {
"target": "http://localhost:8888",
"secure": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,204 @@ describe("BrowseSectionComponent", () => {
component.ngOnInit();
expect(component.entityRoutes[201]).toEqual([HUB_DATASET_RESULT_DETAIL, "201"]);
});
<<<<<<< HEAD
=======

it("falls back to the default background when no cover was cached", () => {
// No coverImageUrl -> loadCoverImages never asks the descriptor -> getCoverImage defaults.
const entity = { id: 6, type: "dataset", accessibleUserIds: [] } as unknown as DashboardEntry;
component.entities = [entity];
component.ngOnInit();

expect(component.getCoverImage(entity)).toBe(component.defaultBackground);
});

// `this.resourceRegistry.find(entity.type)?.coverUrl` carries two guards, and a mixed section
// can trip either. A workflow's cover is a data URL carried on the entry itself, so
// WorkflowResourceDescriptor deliberately declares no `coverUrl`; and a kind the registry does
// not carry at all has no descriptor to ask, which is why this is `find`, not `get` — one such
// row must not take the whole section's covers down, exactly as `routeFor` five lines up
// already promises for links.
it("skips an entity whose descriptor resolves no cover, rather than calling undefined", () => {
const workflow = {
id: 10,
type: "workflow",
coverImageUrl: "carried-on-the-entry",
accessibleUserIds: [],
} as unknown as DashboardEntry;
const unregistered = {
id: 12,
type: "computing-unit",
coverImageUrl: "carried-on-the-entry",
accessibleUserIds: [],
} as unknown as DashboardEntry;
component.entities = [workflow, unregistered];

expect(() => component.ngOnInit()).not.toThrow();
expect(coverCache(component).has("workflow:10")).toBe(false);
expect(coverCache(component).has("computing-unit:12")).toBe(false);
// Nothing is cached for a workflow, but its cover is readable straight off the entry.
expect(component.getCoverImage(workflow)).toBe("carried-on-the-entry");
expect(component.getCoverImage(unregistered)).toBe(component.defaultBackground);
});

it("renders a workflow's cover from the entry, since no cover is ever fetched for one", () => {
const withCover = {
id: 20,
type: "workflow",
coverImageUrl: "data:image/png;base64,AAAA",
accessibleUserIds: [],
} as unknown as DashboardEntry;
const withoutCover = { id: 21, type: "workflow", accessibleUserIds: [] } as unknown as DashboardEntry;
component.entities = [withCover, withoutCover];
component.ngOnInit();

expect(component.getCoverImage(withCover)).toBe("data:image/png;base64,AAAA");
expect(component.getCoverImage(withoutCover)).toBe(component.defaultBackground);
});

it("keeps a file-backed kind on the placeholder rather than rendering its stored cover path", () => {
// A dataset's coverImageUrl is a path relative to the dataset root, not something an <img>
// can load, so it must never stand in for the presigned URL the descriptor resolves.
vi.spyOn(TestBed.inject(DatasetService) as any, "getDatasetCoverUrl").mockReturnValue(of({ url: "" }));
const entity = {
id: 22,
type: "dataset",
coverImageUrl: "v1/images/preview.png",
accessibleUserIds: [],
} as unknown as DashboardEntry;
component.entities = [entity];
component.ngOnInit();

expect(component.getCoverImage(entity)).toBe(component.defaultBackground);
});

it("caches nothing when the descriptor resolves an empty cover url", () => {
// A presigned-URL endpoint with nothing to sign answers with an empty string; caching that
// would put an <img src=""> on the card, which the browser resolves to the page itself.
vi.spyOn(TestBed.inject(DatasetService) as any, "getDatasetCoverUrl").mockReturnValue(of({ url: "" }));
const entity = {
id: 11,
type: "dataset",
coverImageUrl: "has-cover",
accessibleUserIds: [],
} as unknown as DashboardEntry;
component.entities = [entity];
component.ngOnInit();

// White-box on purpose: getCoverImage's `|| defaultBackground` makes "cached an empty string"
// and "cached nothing" indistinguishable through the public API, so only the map itself can
// say whether the guard ran.
expect(coverCache(component).has("dataset:11")).toBe(false);
expect(component.getCoverImage(entity)).toBe(component.defaultBackground);
});

/** The component's cover cache, which no public member exposes. */
function coverCache(c: BrowseSectionComponent): Map<string, string> {
return (c as unknown as { coverImageUrls: Map<string, string> }).coverImageUrls;
}
});
});
/**
* The cards themselves are template-only: the specs above assert the route map and the cover-URL
* cache, but nothing had ever rendered a card, so the per-entity bindings and their fallbacks were
* unpinned. RouterTestingModule supplies the Router that the cards' routerLink needs.
*/
describe("BrowseSectionComponent rendering", () => {
let fixture: ComponentFixture<BrowseSectionComponent>;

beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [BrowseSectionComponent, RouterTestingModule.withRoutes([])],
providers: [
// The cards embed texera-user-avatar, which injects UserService; the real one drags in
// AuthService and its whole dependency chain, so the shared stub stands in for it.
{ provide: UserService, useClass: StubUserService },
{ provide: WorkflowPersistService, useValue: {} },
// The cover now comes from the descriptor, so the double has to answer for it.
{ provide: DatasetService, useValue: { getDatasetCoverUrl: () => of({ url: PRESIGNED_COVER }) } },
{ provide: ModelService, useValue: { getModelCoverUrl: () => of({ url: PRESIGNED_COVER }) } },
...commonTestProviders,
],
});
fixture = TestBed.createComponent(BrowseSectionComponent);
});

/** Renders the section with the given entities. */
function render(entities: DashboardEntry[], title = "Workflows"): HTMLElement {
// Set the inputs and let the first change-detection cycle drive ngOnInit, as Angular does at
// runtime. Calling ngOnInit() by hand as well would run it twice and rebuild the cover-image
// cache on top of itself, hiding any non-idempotent init.
fixture.componentRef.setInput("entities", entities);
fixture.componentRef.setInput("sectionTitle", title);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}

const entity = (over: Partial<Record<string, unknown>> = {}) =>
({ id: 1, type: "dataset", accessibleUserIds: [], name: "flow", ...over }) as unknown as DashboardEntry;

it("renders nothing at all for an empty section", () => {
const el = render([]);

expect(el.querySelector(".results-container")).toBeNull();
});

it("renders the section heading and one card per entity", () => {
const el = render([entity({ id: 1 }), entity({ id: 2 })], "Public Datasets");

expect(el.querySelector(".results-title")?.textContent?.trim()).toBe("Public Datasets");
expect(el.querySelectorAll("nz-card")).toHaveLength(2);
});

it("shows each entity's name and description", () => {
const el = render([entity({ name: "sales", description: "quarterly numbers" })]);

expect(el.querySelector(".card-title")?.textContent?.trim()).toBe("sales");
expect(el.querySelector(".card-description")?.textContent?.trim()).toBe("quarterly numbers");
});

it("substitutes a placeholder for a missing description", () => {
// Datasets published without a description would otherwise render an empty paragraph and
// collapse the card's layout.
const el = render([entity({ description: undefined })]);

expect(el.querySelector(".card-description")?.textContent?.trim()).toBe("No description available");
});

it("uses the cached cover image when the entity has one", () => {
const el = render([entity({ id: 5, coverImageUrl: "has-cover" })]);

const img = el.querySelector<HTMLImageElement>(".card-cover-image")!;
expect(img.getAttribute("src")).toBe(PRESIGNED_COVER);
});

it("falls back to the default background when the cover image fails to load", () => {
// A presigned cover URL can still 404; the inline error handler is the only thing that stops the
// card from showing a broken image.
const el = render([entity({ id: 5, coverImageUrl: "has-cover" })]);
const img = el.querySelector<HTMLImageElement>(".card-cover-image")!;

img.dispatchEvent(new Event("error"));

expect(img.src).toContain("card_background.jpg");
});

it("labels the avatar with the entity id", () => {
const el = render([entity({ id: 42 })]);

expect(el.querySelector("nz-avatar")?.textContent?.trim()).toBe("42");
});

it("passes the owner through to the avatar, defaulting to an empty name", () => {
const withOwner = fixture.debugElement.queryAll(By.css("texera-user-avatar"));
expect(withOwner).toHaveLength(0);

render([entity({ ownerName: "ada" }), entity({ id: 2, ownerName: undefined })]);

const avatars = fixture.debugElement.queryAll(By.css("texera-user-avatar"));
expect(avatars.map(a => a.componentInstance.userName)).toEqual(["ada", ""]);
>>>>>>> 1facefb18 (fix(frontend): render workflow covers on the hub landing page (#8383))
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
import { DashboardEntry } from "../../../dashboard/type/dashboard-entry";
<<<<<<< HEAD
import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service";
import { DatasetService } from "../../../dashboard/service/user/dataset/dataset.service";
import { UntilDestroy } from "@ngneat/until-destroy";
Expand All @@ -29,6 +30,11 @@ import {
USER_WORKSPACE,
} from "../../../app-routing.constant";
import { AppSettings } from "../../../common/app-setting";
=======
import { EntityType } from "../../service/hub.service";
import { ResourceRegistryService } from "../../../dashboard/service/user/resource-registry/resource-registry.service";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
>>>>>>> 1facefb18 (fix(frontend): render workflow covers on the hub landing page (#8383))
import { NgIf, NgFor, NgStyle, DatePipe } from "@angular/common";
import { NzCardComponent } from "ng-zorro-antd/card";
import { RouterLink } from "@angular/router";
Expand Down Expand Up @@ -130,6 +136,16 @@ export class BrowseSectionComponent implements OnInit, OnChanges {
}

getCoverImage(entity: DashboardEntry): string {
<<<<<<< HEAD
return this.coverImageUrls.get(entity.id!) || this.defaultBackground;
=======
// A workflow's cover is a downscaled data URL carried on the entry, so nothing is ever fetched
// for it. The file-backed kinds carry a stored path instead, which only the cache above can
// turn into something an <img> can load.
if (entity.type === EntityType.Workflow) {
return entity.coverImageUrl ?? this.defaultBackground;
}
return this.coverImageUrls.get(this.cacheKey(entity)) || this.defaultBackground;
>>>>>>> 1facefb18 (fix(frontend): render workflow covers on the hub landing page (#8383))
}
}