Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
<!-- Placeholder or Preview Image -->
<img
class="card-preview-image"
[src]="previewImage"
[src]="coverImageSrc"
(error)="onCoverError()"
alt="Workflow Preview" />
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { commonTestProviders } from "../../../../../common/testing/test-utils";
import type { Mocked } from "vitest";
import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry";
import { HUB_WORKFLOW_RESULT_DETAIL, USER_WORKSPACE } from "../../../../../app-routing.constant";
import { DatasetService } from "../../../../service/user/dataset/dataset.service";

function makeWorkflowEntry(overrides: Partial<DashboardEntry> = {}): DashboardEntry {
return {
Expand All @@ -48,18 +49,37 @@ function makeWorkflowEntry(overrides: Partial<DashboardEntry> = {}): DashboardEn
} as unknown as DashboardEntry;
}

function makeDatasetEntry(overrides: Partial<DashboardEntry> = {}): DashboardEntry {
return {
id: 5,
name: "ds",
description: "",
type: "dataset",
dataset: { isOwner: true },
accessibleUserIds: [],
likeCount: 0,
viewCount: 0,
isLiked: false,
size: 0,
...overrides,
} as unknown as DashboardEntry;
}

describe("CardItemComponent", () => {
let component: CardItemComponent;
let fixture: ComponentFixture<CardItemComponent>;
let workflowPersistService: Mocked<WorkflowPersistService>;
let datasetService: Mocked<DatasetService>;

beforeEach(async () => {
const workflowPersistServiceSpy = { updateWorkflowName: vi.fn(), updateWorkflowDescription: vi.fn() };
const datasetServiceSpy = { getDatasetCoverUrl: vi.fn() };

await TestBed.configureTestingModule({
imports: [CardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule],
providers: [
{ provide: WorkflowPersistService, useValue: workflowPersistServiceSpy },
{ provide: DatasetService, useValue: datasetServiceSpy },
{ provide: UserService, useClass: StubUserService },
NzModalService,
...commonTestProviders,
Expand All @@ -69,6 +89,7 @@ describe("CardItemComponent", () => {
fixture = TestBed.createComponent(CardItemComponent);
component = fixture.componentInstance;
workflowPersistService = TestBed.inject(WorkflowPersistService) as unknown as Mocked<WorkflowPersistService>;
datasetService = TestBed.inject(DatasetService) as unknown as Mocked<DatasetService>;
component.entry = makeWorkflowEntry();
fixture.detectChanges();
});
Expand Down Expand Up @@ -157,4 +178,49 @@ describe("CardItemComponent", () => {
expect((entry as any).checked).toBe(true);
expect(spy).toHaveBeenCalledTimes(1);
});

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" });
component.ngOnChanges({ entry: { currentValue: component.entry } as any });

expect(datasetService.getDatasetCoverUrl).toHaveBeenCalledWith(5);
expect(component.coverImageSrc).toBe("https://cover.example/img.png");
});

it("should fall back to the default preview when the cover fetch fails", () => {
datasetService.getDatasetCoverUrl.mockReturnValue(throwError(() => new Error("cover fetch failed")));
component.entry = makeDatasetEntry({ coverImageUrl: "cover/path.png" });
component.ngOnChanges({ entry: { currentValue: component.entry } as any });

expect(component.coverImageSrc).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
});

it("should reset the preview to the default image on cover load error", () => {
component.coverImageSrc = "https://cover.example/img.png";
component.onCoverError();
expect(component.coverImageSrc).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
});

it("should keep the default preview for non-dataset entries", () => {
component.entry = makeWorkflowEntry();
component.ngOnChanges({ entry: { currentValue: component.entry } as any });
expect(component.coverImageSrc).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
});

it("should not fetch a cover when the dataset has no cover image", () => {
component.entry = makeDatasetEntry({ coverImageUrl: undefined });
component.ngOnChanges({ entry: { currentValue: component.entry } as any });

expect(datasetService.getDatasetCoverUrl).not.toHaveBeenCalled();
expect(component.coverImageSrc).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
});

it("should use the default preview when the cover url resolves to null", () => {
datasetService.getDatasetCoverUrl.mockReturnValue(of({ url: null }));
component.entry = makeDatasetEntry({ coverImageUrl: "cover/path.png" });
component.ngOnChanges({ entry: { currentValue: component.entry } as any });

expect(component.coverImageSrc).toBe(CardItemComponent.DEFAULT_PREVIEW_IMAGE);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export class CardItemComponent implements OnChanges {
hovering: boolean = false;
/** The default top image, used when the user has not uploaded a custom one. */
static readonly DEFAULT_PREVIEW_IMAGE = "assets/card_background.jpg";
/** Resolved preview/cover image; stays the placeholder until a dataset cover loads. */
coverImageSrc: string = CardItemComponent.DEFAULT_PREVIEW_IMAGE;

@Input()
get entry(): DashboardEntry {
Expand Down Expand Up @@ -134,11 +136,6 @@ export class CardItemComponent implements OnChanges {
private notificationService: NotificationService
) {}

/** The top image src for the card preview. */
get previewImage(): string {
return CardItemComponent.DEFAULT_PREVIEW_IMAGE;
}

initializeEntry() {
if (this.entry.type === "workflow") {
if (typeof this.entry.id === "number") {
Expand Down Expand Up @@ -166,6 +163,7 @@ export class CardItemComponent implements OnChanges {
}
this.iconType = "database";
this.size = this.entry.size;
this.loadDatasetCover(this.entry.id);
}
} else if (this.entry.type === "file") {
// not sure where to redirect
Expand All @@ -184,6 +182,31 @@ export class CardItemComponent implements OnChanges {
}
}

/** Loads the dataset cover into the preview slot, falling back to the placeholder. */
private loadDatasetCover(did: number): void {
if (!this.entry.coverImageUrl) {
return;
}
this.datasetService
.getDatasetCoverUrl(did)
.pipe(untilDestroyed(this))
.subscribe({
next: ({ url }) => {
this.coverImageSrc = url ?? CardItemComponent.DEFAULT_PREVIEW_IMAGE;
this.cdr.markForCheck();
},
error: () => {
this.coverImageSrc = CardItemComponent.DEFAULT_PREVIEW_IMAGE;
this.cdr.markForCheck();
},
});
}

/** Falls the preview back to the placeholder if the cover image fails to load. */
onCoverError(): void {
this.coverImageSrc = CardItemComponent.DEFAULT_PREVIEW_IMAGE;
}

onCheckboxChange(entry: DashboardEntry): void {
entry.checked = !entry.checked;
this.cdr.markForCheck();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ <h2 class="page-title">Datasets</h2>
nzTheme="outline"></i>
<span>Create Dataset</span>
</button>
<button
nz-button
title="List View"
(click)="setViewType('list')"
[nzType]="viewType === 'list' ? 'primary' : 'default'">
<i
nz-icon
nzType="bars"
nzTheme="outline"></i>
</button>
<button
nz-button
title="Card View"
(click)="setViewType('card')"
[nzType]="viewType === 'card' ? 'primary' : 'default'">
<i
nz-icon
nzType="appstore"
nzTheme="outline"></i>
</button>
<texera-filters #filters></texera-filters>
</div>
</nz-card>
Expand All @@ -52,9 +72,24 @@ <h2 class="page-title">Datasets</h2>
</nz-select>
</div>

<ng-template
#datasetCardTpl
let-entry>
<texera-card-item
[entry]="entry"
[currentUid]="currentUid"
[isPrivateSearch]="true"
[editable]="true"
(deleted)="deleteDataset(entry)"
(refresh)="search(true)">
</texera-card-item>
</ng-template>

<texera-search-results
[editable]="true"
[isPrivateSearch]="true"
[viewMode]="viewType"
[cardTemplate]="datasetCardTpl"
(deleted)="deleteDataset($event)"
(refresh)="ngAfterViewInit()"
[currentUid]="currentUid"></texera-search-results>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,4 +309,55 @@ describe("UserDatasetComponent", () => {
expect(searchResultsStub.entries).toEqual([e1, e3]);
});
});

describe("view mode toggle", () => {
const VIEW_MODE_KEY = "texera.userDataset.viewMode";

afterEach(() => localStorage.removeItem(VIEW_MODE_KEY));

it("setViewType updates viewType, persists it, and is a no-op when unchanged", () => {
// viewType defaults to "card", so switching to "list" is the real change
component.setViewType("list");
expect(component.viewType).toBe("list");
expect(localStorage.getItem(VIEW_MODE_KEY)).toBe("list");

// setting the same value should not write again
localStorage.removeItem(VIEW_MODE_KEY);
component.setViewType("list");
expect(localStorage.getItem(VIEW_MODE_KEY)).toBeNull();

component.setViewType("card");
expect(component.viewType).toBe("card");
expect(localStorage.getItem(VIEW_MODE_KEY)).toBe("card");
});

const makeFreshComponent = () => {
const userServiceMock = {
userChanged: () => new Subject<User | undefined>().asObservable(),
isLogin: () => true,
getCurrentUser: () => ({ uid: 42 }) as User,
};
return new UserDatasetComponent(
modalServiceMock as any,
userServiceMock as any,
routerMock as any,
searchServiceMock as any,
datasetServiceMock as any,
messageMock as any
);
};

it("defaults viewType to card when nothing is stored", () => {
localStorage.removeItem(VIEW_MODE_KEY);
expect(makeFreshComponent().viewType).toBe("card");
});

it("initializes viewType to list only when explicitly stored", () => {
localStorage.setItem(VIEW_MODE_KEY, "list");
expect(makeFreshComponent().viewType).toBe("list");

localStorage.setItem(VIEW_MODE_KEY, "card");
expect(makeFreshComponent().viewType).toBe("card");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { DatasetService } from "../../../service/user/dataset/dataset.service";
import { SortMethod } from "../../../type/sort-method";
import { DashboardEntry } from "../../../type/dashboard-entry";
import { SearchResultsComponent } from "../search-results/search-results.component";
import { CardItemComponent } from "../list-item/card-item/card-item.component";
import { FiltersComponent } from "../filters/filters.component";
import { firstValueFrom } from "rxjs";
import { USER_DATASET } from "../../../../app-routing.constant";
Expand Down Expand Up @@ -61,14 +62,18 @@ import { FormsModule } from "@angular/forms";
NzSelectComponent,
FormsModule,
SearchResultsComponent,
CardItemComponent,
],
})
export class UserDatasetComponent implements AfterViewInit {
private static readonly VIEW_MODE_STORAGE_KEY = "texera.userDataset.viewMode";
public sortMethod = SortMethod.EditTimeDesc;
lastSortMethod: SortMethod | null = null;
public isLogin = this.userService.isLogin();
public currentUid = this.userService.getCurrentUser()?.uid;
public hasMismatch = false; // Display warning when there are mismatched datasets
public viewType: "list" | "card" =
localStorage.getItem(UserDatasetComponent.VIEW_MODE_STORAGE_KEY) === "list" ? "list" : "card";

private _searchResultsComponent?: SearchResultsComponent;
@ViewChild(SearchResultsComponent) get searchResultsComponent(): SearchResultsComponent {
Expand Down Expand Up @@ -120,6 +125,14 @@ export class UserDatasetComponent implements AfterViewInit {
.subscribe(() => this.search());
}

public setViewType(viewType: "list" | "card"): void {
if (this.viewType === viewType) {
return;
}
this.viewType = viewType;
localStorage.setItem(UserDatasetComponent.VIEW_MODE_STORAGE_KEY, viewType);
}

/*
* Executes a dataset search with filtering, sorting.
*
Expand Down
Loading