From 52dac0196c31eb8f22d421c26ff2c3a47540440e Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Sun, 24 May 2026 22:41:13 -0700 Subject: [PATCH 1/4] update. --- .../dashboard/DatasetSearchQueryBuilder.scala | 3 +- .../dashboard/UnifiedResourceSchema.scala | 6 +- frontend/src/app/common/util/format.util.ts | 37 ++++ .../search-results.component.html | 83 +++++--- .../search-results.component.scss | 18 ++ .../search-results.component.ts | 6 + .../user-dataset-card-item.component.html | 139 ++++++++++++++ .../user-dataset-card-item.component.scss | 181 ++++++++++++++++++ .../user-dataset-card-item.component.spec.ts | 141 ++++++++++++++ .../user-dataset-card-item.component.ts | 173 +++++++++++++++++ .../user-dataset/user-dataset.component.html | 27 +++ .../user-dataset/user-dataset.component.scss | 28 +++ .../user-dataset/user-dataset.component.ts | 17 ++ .../user/dashboard-entry-actions.service.ts | 58 ++++++ frontend/src/app/hub/service/hub.service.ts | 21 ++ 15 files changed, 905 insertions(+), 33 deletions(-) create mode 100644 frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.html create mode 100644 frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss create mode 100644 frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts create mode 100644 frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts create mode 100644 frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala index 89fe805d58c..64c8c311068 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/DatasetSearchQueryBuilder.scala @@ -48,7 +48,8 @@ object DatasetSearchQueryBuilder extends SearchQueryBuilder with LazyLogging { repositoryName = DATASET.REPOSITORY_NAME, isDatasetPublic = DATASET.IS_PUBLIC, isDatasetDownloadable = DATASET.IS_DOWNLOADABLE, - datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE + datasetUserAccess = DATASET_USER_ACCESS.PRIVILEGE, + datasetCoverImage = DATASET.COVER_IMAGE ) /* diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala index dbcf1926407..8c4ecda946b 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/UnifiedResourceSchema.scala @@ -72,7 +72,8 @@ object UnifiedResourceSchema { repositoryName: Field[String] = DSL.inline(""), isDatasetPublic: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]), isDatasetDownloadable: Field[java.lang.Boolean] = DSL.cast(null, classOf[java.lang.Boolean]), - datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]) + datasetUserAccess: Field[PrivilegeEnum] = DSL.castNull(classOf[PrivilegeEnum]), + datasetCoverImage: Field[String] = DSL.cast(null, classOf[String]) ): UnifiedResourceSchema = { new UnifiedResourceSchema( Seq( @@ -96,7 +97,8 @@ object UnifiedResourceSchema { repositoryName -> repositoryName.as("repository_name"), isDatasetPublic -> isDatasetPublic.as("is_dataset_public"), isDatasetDownloadable -> isDatasetDownloadable.as("is_dataset_downloadable"), - datasetUserAccess -> datasetUserAccess.as("user_dataset_access") + datasetUserAccess -> datasetUserAccess.as("user_dataset_access"), + datasetCoverImage -> datasetCoverImage.as("cover_image") ) ) } diff --git a/frontend/src/app/common/util/format.util.ts b/frontend/src/app/common/util/format.util.ts index 2ac5f229796..8de69332d59 100644 --- a/frontend/src/app/common/util/format.util.ts +++ b/frontend/src/app/common/util/format.util.ts @@ -54,3 +54,40 @@ export const formatTime = (seconds?: number): string => { return min === 0 ? `${h}h` : `${h}h${min}m`; }; + +/** Format a count: "1.5k" for >= 1000, otherwise the number. */ +export const formatCount = (count: number): string => { + if (count >= 1000) return (count / 1000).toFixed(1) + "k"; + return String(count); +}; + +/** Format a timestamp as relative time ("5 minutes ago", "3 months ago", "1 year ago"). */ +export const formatRelativeTime = (timestamp: number | undefined): string => { + if (timestamp === undefined) { + return "Unknown"; + } + + const currentTime = new Date().getTime(); + const timeDifference = currentTime - timestamp; + + const minutesAgo = Math.floor(timeDifference / (1000 * 60)); + const hoursAgo = Math.floor(timeDifference / (1000 * 60 * 60)); + const daysAgo = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); + const weeksAgo = Math.floor(daysAgo / 7); + const monthsAgo = Math.floor(daysAgo / 30); + const yearsAgo = Math.floor(daysAgo / 365); + + if (minutesAgo < 60) { + return `${minutesAgo} minutes ago`; + } else if (hoursAgo < 24) { + return `${hoursAgo} hours ago`; + } else if (daysAgo < 7) { + return `${daysAgo} days ago`; + } else if (weeksAgo < 4) { + return `${weeksAgo} weeks ago`; + } else if (monthsAgo < 12) { + return `${monthsAgo} months ago`; + } else { + return `${yearsAgo} years ago`; + } +}; diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html index d623b981532..e90931c596e 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html @@ -20,36 +20,59 @@ - - - - - + + + + + + + + +
+ +
+
+
+ + + +
+
+ - - - -
- + [editable]="editable" + [currentUid]="currentUid" + (deleted)="deleted.emit(entry)" + (refresh)="refresh.emit()"> + +
+
+ +
- + diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss b/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss index 73ccb3127ae..e5e237e23a5 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.scss @@ -130,3 +130,21 @@ nz-content { margin: 0 1rem 0 0; // add space to the right } } + +.card-scroll-container { + height: 100%; + overflow-y: auto; + padding: 12px; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; + justify-content: start; + align-items: stretch; + + * { + max-width: 300px; + } +} diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts b/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts index 601a6534a53..fe372dfe2aa 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.ts @@ -25,12 +25,14 @@ import { ɵɵCdkVirtualScrollViewport, ɵɵCdkFixedSizeVirtualScroll } from "@an import { NzListComponent } from "ng-zorro-antd/list"; import { NgFor, NgIf } from "@angular/common"; import { ListItemComponent } from "../list-item/list-item.component"; +import { UserDatasetCardItemComponent } from "../user-dataset/user-dataset-card-item/user-dataset-card-item.component"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; export type LoadMoreFunction = (start: number, count: number) => Promise<{ entries: DashboardEntry[]; more: boolean }>; +export type SearchResultsViewMode = "list" | "card"; @Component({ selector: "texera-search-results", @@ -43,6 +45,7 @@ export type LoadMoreFunction = (start: number, count: number) => Promise<{ entri NzListComponent, NgFor, ListItemComponent, + UserDatasetCardItemComponent, NgIf, NzSpaceCompactItemDirective, NzButtonComponent, @@ -62,6 +65,9 @@ export class SearchResultsComponent { @Input() editable = false; @Input() searchKeywords: string[] = []; @Input() currentUid: number | undefined; + @Input() viewMode: SearchResultsViewMode = "list"; + + trackByEntryId = (_: number, entry: DashboardEntry): string => `${entry.type}-${entry.id}`; @Output() deleted = new EventEmitter(); @Output() duplicated = new EventEmitter(); @Output() modified = new EventEmitter(); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.html new file mode 100644 index 00000000000..8aa68163234 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.html @@ -0,0 +1,139 @@ + + + + + + + +
+ dataset cover + #{{ entry.id }} +
+
+ + +
    +
  • + + Share +
  • +
  • + + Download +
  • +
  • + + Delete +
  • +
+
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss new file mode 100644 index 00000000000..0d0fe2bef26 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss @@ -0,0 +1,181 @@ +/** + * 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. + */ + +.dataset-card { + height: 100%; + display: flex; + flex-direction: column; + border-radius: 8px; + overflow: hidden; + cursor: pointer; +} + +.dataset-card-body-link { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + color: inherit; +} + +.cover-container { + position: relative; + height: 124px; + background: #f5f5f5; + overflow: hidden; + + .cover-image { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .cover-id-badge { + position: absolute; + left: 8px; + bottom: 8px; + padding: 2px 8px; + font-size: 12px; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + background: rgba(0, 0, 0, 0.55); + color: white; + } +} + +.card-title-row { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 10px; + + .card-title { + flex: 1; + min-width: 0; + height: calc(15px * 1.35 * 2); + font-size: 15px; + font-weight: 600; + line-height: 1.35; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + word-break: break-word; + } + + .more-btn { + flex-shrink: 0; + width: 26px; + height: 26px; + border: none; + background: transparent; + color: #8c8c8c; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + i { + font-size: 18px; + } + } +} + +.truncate-single-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-meta { + display: flex; + flex-direction: column; + gap: 5px; + margin-top: auto; + padding-top: 10px; + border-top: 1px solid #f0f0f0; + min-width: 0; + + .meta-line { + display: flex; + align-items: center; + gap: 6px; + color: #595959; + min-width: 0; + + &--owner { + font-size: 13px; + + .meta-owner { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + } + + .meta-avatar { + flex-shrink: 0; + + ::ng-deep nz-avatar.ant-avatar { + width: 20px; + height: 20px; + line-height: 20px; + font-size: 10px; + } + ::ng-deep .owner-badge { + font-size: 9px; + } + } + } + + &--stats, + &--activity { + justify-content: space-between; + font-size: 12px; + color: #8c8c8c; + } + + &--stats .meta-size { + white-space: nowrap; + } + + .meta-stat--like { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + cursor: pointer; + + i { + font-size: 12px; + } + + &.liked, + &:not(.disabled):hover { + color: #ff4d4f; + } + + &.disabled { + cursor: default; + } + } + } +} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts new file mode 100644 index 00000000000..195bef1c323 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts @@ -0,0 +1,141 @@ +/** + * 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 { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; +import { RouterTestingModule } from "@angular/router/testing"; +import { of } from "rxjs"; +import type { Mocked } from "vitest"; + +import { UserDatasetCardItemComponent } from "./user-dataset-card-item.component"; +import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; +import { DashboardEntryActionsService } from "../../../../service/user/dashboard-entry-actions.service"; +import { HubService } from "../../../../../hub/service/hub.service"; +import { UserService } from "../../../../../common/service/user/user.service"; +import { StubUserService } from "../../../../../common/service/user/stub-user.service"; +import { AppSettings } from "../../../../../common/app-setting"; +import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../../../../../app-routing.constant"; +import { commonTestProviders } from "../../../../../common/testing/test-utils"; + +function makeDatasetEntry(overrides: Partial = {}): DashboardEntry { + // Only includes fields read by the component's logic; template fields are skipped + return { + type: "dataset", + id: 42, + accessibleUserIds: [1, 2], + coverImageUrl: undefined, + likeCount: 5, + isLiked: false, + ...overrides, + } as unknown as DashboardEntry; +} + +describe("UserDatasetCardItemComponent", () => { + let component: UserDatasetCardItemComponent; + let fixture: ComponentFixture; + let hubService: Mocked; + + beforeEach(async () => { + const entryActionsSpy = { + openShareAccess: vi.fn().mockResolvedValue(undefined), + download: vi.fn(), + }; + const hubServiceSpy = { + toggleLike: vi.fn().mockReturnValue(of({ liked: true, likeCount: 7 })), + }; + + await TestBed.configureTestingModule({ + imports: [UserDatasetCardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], + providers: [ + { provide: DashboardEntryActionsService, useValue: entryActionsSpy }, + { provide: HubService, useValue: hubServiceSpy }, + { provide: UserService, useClass: StubUserService }, + ...commonTestProviders, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(UserDatasetCardItemComponent); + component = fixture.componentInstance; + hubService = TestBed.inject(HubService) as unknown as Mocked; + }); + + describe("entryLink", () => { + it("routes to the private dataset page when the current user has access", () => { + component.currentUid = 1; + component.entry = makeDatasetEntry({ id: 99, accessibleUserIds: [1, 2] }); + component.ngOnChanges({ entry: { currentValue: component.entry } } as any); + expect(component.entryLink).toEqual([DASHBOARD_USER_DATASET, "99"]); + }); + + it("routes to the hub detail page when the current user has no access", () => { + component.currentUid = 5; + component.entry = makeDatasetEntry({ id: 99, accessibleUserIds: [1, 2] }); + component.ngOnChanges({ entry: { currentValue: component.entry } } as any); + expect(component.entryLink).toEqual([DASHBOARD_HUB_DATASET_RESULT_DETAIL, "99"]); + }); + }); + + describe("coverImageSrc", () => { + it("falls back to the default cover when coverImageUrl is missing", () => { + component.entry = makeDatasetEntry({ coverImageUrl: undefined }); + component.ngOnChanges({ entry: { currentValue: component.entry } } as any); + expect(component.coverImageSrc).toBe(component.defaultCover); + }); + + it("builds the API URL when coverImageUrl is set", () => { + component.entry = makeDatasetEntry({ id: 7, coverImageUrl: "v1/img.png" }); + component.ngOnChanges({ entry: { currentValue: component.entry } } as any); + expect(component.coverImageSrc).toBe(`${AppSettings.getApiEndpoint()}/dataset/7/cover`); + }); + }); + + describe("toggleLike", () => { + beforeEach(() => { + component.currentUid = 1; + component.entry = makeDatasetEntry(); + component.ngOnChanges({ entry: { currentValue: component.entry } } as any); + }); + + it("does nothing when the user is not signed in", () => { + component.currentUid = undefined; + component.toggleLike(); + expect(hubService.toggleLike).not.toHaveBeenCalled(); + }); + + it("toggles to liked and reconciles state from the server", () => { + component.isLiked = false; + component.toggleLike(); + expect(hubService.toggleLike).toHaveBeenCalledWith(42, "dataset", false); + expect(component.isLiked).toBe(true); + expect(component.likeCount).toBe(7); + }); + + it("toggles to unliked and reconciles state from the server", () => { + hubService.toggleLike.mockReturnValueOnce(of({ liked: false, likeCount: 6 })); + component.isLiked = true; + component.toggleLike(); + expect(hubService.toggleLike).toHaveBeenCalledWith(42, "dataset", true); + expect(component.isLiked).toBe(false); + expect(component.likeCount).toBe(6); + }); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts new file mode 100644 index 00000000000..3d01e48bced --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts @@ -0,0 +1,173 @@ +/** + * 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 { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, +} from "@angular/core"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { NgIf } from "@angular/common"; +import { RouterLink } from "@angular/router"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; +import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; +import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; +import { DashboardEntry } from "../../../../type/dashboard-entry"; +import { UserAvatarComponent } from "../../user-avatar/user-avatar.component"; +import { DashboardEntryActionsService } from "../../../../service/user/dashboard-entry-actions.service"; +import { HubService } from "../../../../../hub/service/hub.service"; +import { AppSettings } from "../../../../../common/app-setting"; +import { formatSize } from "../../../../../common/util/size-formatter.util"; +import { formatCount, formatRelativeTime } from "../../../../../common/util/format.util"; +import { isDefined } from "../../../../../common/util/predicate"; +import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../../../../../app-routing.constant"; + +@UntilDestroy() +@Component({ + selector: "texera-dataset-card-item", + templateUrl: "./user-dataset-card-item.component.html", + styleUrls: ["./user-dataset-card-item.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + NgIf, + RouterLink, + NzCardComponent, + NzIconDirective, + NzPopconfirmDirective, + NzTooltipModule, + NzDropdownDirective, + NzDropdownMenuComponent, + NzMenuDirective, + NzMenuItemComponent, + UserAvatarComponent, + ], +}) +export class UserDatasetCardItemComponent implements OnChanges { + @Input() editable = false; + @Input() currentUid: number | undefined; + @Output() deleted = new EventEmitter(); + @Output() refresh = new EventEmitter(); + + private _entry?: DashboardEntry; + @Input() + get entry(): DashboardEntry { + if (!this._entry) { + throw new Error("entry property must be provided."); + } + return this._entry; + } + set entry(value: DashboardEntry) { + this._entry = value; + } + + entryLink: string[] = []; + coverImageSrc: string = ""; + readonly defaultCover = "assets/card_background.jpg"; + likeCount = 0; + viewCount = 0; + isLiked = false; + + constructor( + private entryActions: DashboardEntryActionsService, + private hubService: HubService, + private cdr: ChangeDetectorRef + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes["entry"] || changes["currentUid"]) { + this.initializeEntry(); + } + if (changes["entry"]) { + this.likeCount = this.entry.likeCount ?? 0; + this.viewCount = this.entry.viewCount ?? 0; + this.isLiked = this.entry.isLiked ?? false; + } + } + + private initializeEntry(): void { + if (this.entry.type !== "dataset" || typeof this.entry.id !== "number") { + return; + } + const owners = this.entry.accessibleUserIds; + if (this.currentUid !== undefined && owners.includes(this.currentUid)) { + this.entryLink = [DASHBOARD_USER_DATASET, String(this.entry.id)]; + } else { + this.entryLink = [DASHBOARD_HUB_DATASET_RESULT_DETAIL, String(this.entry.id)]; + } + this.coverImageSrc = this.entry.coverImageUrl + ? `${AppSettings.getApiEndpoint()}/dataset/${this.entry.id}/cover` + : this.defaultCover; + } + + onCoverError(event: Event): void { + const image = event.target as HTMLImageElement; + image.onerror = null; + image.src = this.defaultCover; + } + + public async onClickOpenShareAccess(): Promise { + const modal = await this.entryActions.openShareAccess(this.entry); + modal?.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => this.refresh.emit()); + } + + public onClickDownload = (): void => { + this.entryActions.download(this.entry); + }; + + toggleLike(): void { + if (!isDefined(this.currentUid) || !isDefined(this.entry.id)) return; + // Flip optimistically; reconcile or revert when the server responds. + const previousLiked = this.isLiked; + this.isLiked = !previousLiked; + this.likeCount += previousLiked ? -1 : 1; + this.cdr.markForCheck(); + + this.hubService + .toggleLike(this.entry.id, this.entry.type, previousLiked) + .pipe(untilDestroyed(this)) + .subscribe({ + next: ({ liked, likeCount }) => { + this.isLiked = liked; + this.likeCount = likeCount; + this.cdr.markForCheck(); + }, + error: () => { + this.isLiked = previousLiked; + this.likeCount += previousLiked ? 1 : -1; + this.cdr.markForCheck(); + }, + }); + } + + get canDelete(): boolean { + return this.entry.type === "dataset" && this.entry.dataset.isOwner; + } + + formatSize = formatSize; + formatCount = formatCount; + formatRelativeTime = formatRelativeTime; +} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html index 08ce28eac60..917a91f723f 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.html @@ -33,6 +33,32 @@

Datasets

Create Dataset +
+ + +
@@ -55,6 +81,7 @@

Datasets

diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss index 31c95188c44..3a0cb64df5f 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss @@ -25,3 +25,31 @@ height: 55px; overflow-y: hidden; } + +.view-toggle { + display: inline-flex; + background: #f5f5f5; + border-radius: 6px; + padding: 2px; + margin-left: 8px; + + button { + height: 28px; + padding: 0 10px; + border: none; + background: transparent; + color: #8c8c8c; + border-radius: 4px; + + &:hover { + color: #595959; + background: rgba(255, 255, 255, 0.6); + } + + &.active { + background: #fff; + color: #1f1f1f; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + } + } +} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts index 165270c0537..821eeab7aea 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts @@ -43,6 +43,15 @@ import { NzIconDirective } from "ng-zorro-antd/icon"; import { FiltersInstructionsComponent } from "../filters-instructions/filters-instructions.component"; import { NzSelectComponent } from "ng-zorro-antd/select"; import { FormsModule } from "@angular/forms"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; + +export type ViewMode = "list" | "card"; +export const DATASET_VIEW_MODE_STORAGE_KEY = "texera.dataset.viewMode"; + +/** Read the persisted view mode, falling back to "list" if absent or invalid. */ +export function readStoredDatasetViewMode(): ViewMode { + return localStorage.getItem(DATASET_VIEW_MODE_STORAGE_KEY) === "card" ? "card" : "list"; +} @UntilDestroy() @Component({ @@ -56,6 +65,7 @@ import { FormsModule } from "@angular/forms"; NzWaveDirective, ɵNzTransitionPatchDirective, NzIconDirective, + NzTooltipModule, FiltersComponent, FiltersInstructionsComponent, NzSelectComponent, @@ -69,6 +79,13 @@ export class UserDatasetComponent implements AfterViewInit { public isLogin = this.userService.isLogin(); public currentUid = this.userService.getCurrentUser()?.uid; public hasMismatch = false; // Display warning when there are mismatched datasets + public viewMode: ViewMode = readStoredDatasetViewMode(); + + setViewMode(mode: ViewMode): void { + if (this.viewMode === mode) return; + this.viewMode = mode; + localStorage.setItem(DATASET_VIEW_MODE_STORAGE_KEY, mode); + } private _searchResultsComponent?: SearchResultsComponent; @ViewChild(SearchResultsComponent) get searchResultsComponent(): SearchResultsComponent { diff --git a/frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts b/frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts new file mode 100644 index 00000000000..cdd5cc0230a --- /dev/null +++ b/frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts @@ -0,0 +1,58 @@ +/** + * 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 { Injectable } from "@angular/core"; +import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; +import { firstValueFrom } from "rxjs"; +import { ShareAccessComponent } from "../../component/user/share-access/share-access.component"; +import { DashboardEntry } from "../../type/dashboard-entry"; +import { DatasetService } from "./dataset/dataset.service"; +import { DownloadService } from "./download/download.service"; + +/** Dataset actions shared by dashboard list and card views. */ +@Injectable({ providedIn: "root" }) +export class DashboardEntryActionsService { + constructor( + private modalService: NzModalService, + private datasetService: DatasetService, + private downloadService: DownloadService + ) {} + + async openShareAccess(entry: DashboardEntry): Promise | undefined> { + if (entry.type !== "dataset") return undefined; + return this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: entry.accessLevel === "WRITE", + type: "dataset", + id: entry.id, + allOwners: await firstValueFrom(this.datasetService.retrieveOwners()), + }, + nzFooter: null, + nzTitle: "Share this dataset with others", + nzCentered: true, + nzWidth: "700px", + }); + } + + download(entry: DashboardEntry): void { + if (entry.type !== "dataset" || !entry.id) return; + this.downloadService.downloadDataset(entry.id, entry.name).subscribe(); + } +} diff --git a/frontend/src/app/hub/service/hub.service.ts b/frontend/src/app/hub/service/hub.service.ts index 66cd08a9149..7979bcbaca6 100644 --- a/frontend/src/app/hub/service/hub.service.ts +++ b/frontend/src/app/hub/service/hub.service.ts @@ -20,6 +20,7 @@ import { HttpClient, HttpHeaders, HttpParams } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; +import { map, switchMap } from "rxjs/operators"; import { AppSettings } from "../../common/app-setting"; import { SearchResultItem } from "../../dashboard/type/search-result"; @@ -102,6 +103,26 @@ export class HubService { }); } + /** Like/unlike then fetch updated count; emits the final {liked, likeCount}. */ + public toggleLike( + entityId: number, + entityType: EntityType, + currentlyLiked: boolean + ): Observable<{ liked: boolean; likeCount: number }> { + const action$ = currentlyLiked ? this.postUnlike(entityId, entityType) : this.postLike(entityId, entityType); + return action$.pipe( + switchMap(success => + this.getCounts([entityType], [entityId], [ActionType.Like]).pipe( + map(counts => { + const likeCount = counts[0]?.counts.like ?? 0; + const liked = success ? !currentlyLiked : currentlyLiked; + return { liked, likeCount }; + }) + ) + ) + ); + } + public postView(entityId: number, userId: number, entityType: EntityType): Observable { const body = { entityId, userId, entityType }; return this.http.post(`${this.BASE_URL}/view`, body, { From c277c3fa5322e36aac45c06e4825f5c5628e173d Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Mon, 25 May 2026 18:19:10 -0700 Subject: [PATCH 2/4] add card view to Hub datasets list. --- .../dataset-card-item.component.html} | 47 +++++++++---- .../dataset-card-item.component.scss} | 70 +++++++++++++++---- .../dataset-card-item.component.spec.ts} | 36 +++++----- .../dataset-card-item.component.ts} | 65 +++++++++-------- .../search-results.component.html | 15 ++-- .../search-results.component.ts | 9 +-- .../user-dataset/user-dataset.component.html | 27 ------- .../user-dataset/user-dataset.component.scss | 28 -------- .../user-dataset/user-dataset.component.ts | 17 ----- .../user/dashboard-entry-actions.service.ts | 58 --------------- .../hub-search-result.component.html | 41 ++++++++++- .../hub-search-result.component.scss | 35 ++++++++++ .../hub-search-result.component.ts | 31 +++++++- 13 files changed, 259 insertions(+), 220 deletions(-) rename frontend/src/app/dashboard/component/user/{user-dataset/user-dataset-card-item/user-dataset-card-item.component.html => dataset-card-item/dataset-card-item.component.html} (80%) rename frontend/src/app/dashboard/component/user/{user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss => dataset-card-item/dataset-card-item.component.scss} (77%) rename frontend/src/app/dashboard/component/user/{user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts => dataset-card-item/dataset-card-item.component.spec.ts} (78%) rename frontend/src/app/dashboard/component/user/{user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts => dataset-card-item/dataset-card-item.component.ts} (70%) delete mode 100644 frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.html b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html similarity index 80% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.html rename to frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html index 8aa68163234..0d333bd5e88 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.html +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.html @@ -47,38 +47,57 @@
- + - + {{ entry.ownerName || entry.ownerEmail || 'Unknown' }} -
-
+ Updated {{ formatRelativeTime(entry.lastModifiedTime) }} - {{ formatSize(entry.size) }}
-
- {{ formatCount(viewCount) }} views -
+
+
+ + + {{ entry.size ? formatSize(entry.size) : '-' }} + + + + {{ formatCount(viewCount) }} + +
+
diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss similarity index 77% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss rename to frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss index 0d0fe2bef26..c72c2e50cd0 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.scss +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.scss @@ -55,7 +55,7 @@ font-size: 12px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); - background: rgba(0, 0, 0, 0.55); + background: rgba(15, 14, 12, 0.72); color: white; } } @@ -109,8 +109,7 @@ flex-direction: column; gap: 5px; margin-top: auto; - padding-top: 10px; - border-top: 1px solid #f0f0f0; + padding-top: 4px; min-width: 0; .meta-line { @@ -127,7 +126,6 @@ display: inline-flex; align-items: center; gap: 6px; - flex: 1; min-width: 0; } @@ -144,33 +142,64 @@ font-size: 9px; } } + + .meta-dot { + flex-shrink: 0; + color: #bfbfbf; + font-size: 13px; + line-height: 1; + user-select: none; + } + + .meta-updated { + flex-shrink: 0; + font-size: 12px; + color: #8c8c8c; + white-space: nowrap; + } } - &--stats, - &--activity { + &--stats { justify-content: space-between; font-size: 12px; color: #8c8c8c; } - &--stats .meta-size { - white-space: nowrap; - } - - .meta-stat--like { + .meta-stat { display: inline-flex; align-items: center; gap: 4px; flex-shrink: 0; - cursor: pointer; + white-space: nowrap; i { font-size: 12px; } + } + + .meta-stat--like { + padding: 0 10px; + border: 1px solid #e8e8e8; + border-radius: 999px; + background: transparent; + color: inherit; + font-size: 12px; + gap: 8px; + cursor: pointer; + transition: border-color 0.15s; + + i { + font-size: 11px; + transition: color 0.15s; + } + + &.liked i, + &:not(.disabled):not(.liked):hover i { + color: #e0506e; + } - &.liked, &:not(.disabled):hover { - color: #ff4d4f; + border-color: #e0506e; } &.disabled { @@ -178,4 +207,17 @@ } } } + + .meta-stats-left { + display: inline-flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .meta-hr { + height: 1px; + background: #f0f0f0; + margin: 2px 0; + } } diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts similarity index 78% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts rename to frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts index 195bef1c323..bc3ffd11a4f 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts @@ -25,15 +25,17 @@ import { RouterTestingModule } from "@angular/router/testing"; import { of } from "rxjs"; import type { Mocked } from "vitest"; -import { UserDatasetCardItemComponent } from "./user-dataset-card-item.component"; +import { DatasetCardItemComponent } from "./dataset-card-item.component"; import { DashboardEntry } from "src/app/dashboard/type/dashboard-entry"; -import { DashboardEntryActionsService } from "../../../../service/user/dashboard-entry-actions.service"; -import { HubService } from "../../../../../hub/service/hub.service"; -import { UserService } from "../../../../../common/service/user/user.service"; -import { StubUserService } from "../../../../../common/service/user/stub-user.service"; -import { AppSettings } from "../../../../../common/app-setting"; -import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../../../../../app-routing.constant"; -import { commonTestProviders } from "../../../../../common/testing/test-utils"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { DatasetService } from "../../../service/user/dataset/dataset.service"; +import { DownloadService } from "../../../service/user/download/download.service"; +import { HubService } from "../../../../hub/service/hub.service"; +import { UserService } from "../../../../common/service/user/user.service"; +import { StubUserService } from "../../../../common/service/user/stub-user.service"; +import { AppSettings } from "../../../../common/app-setting"; +import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../../../../app-routing.constant"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; function makeDatasetEntry(overrides: Partial = {}): DashboardEntry { // Only includes fields read by the component's logic; template fields are skipped @@ -48,24 +50,22 @@ function makeDatasetEntry(overrides: Partial = {}): DashboardEntry { } as unknown as DashboardEntry; } -describe("UserDatasetCardItemComponent", () => { - let component: UserDatasetCardItemComponent; - let fixture: ComponentFixture; +describe("DatasetCardItemComponent", () => { + let component: DatasetCardItemComponent; + let fixture: ComponentFixture; let hubService: Mocked; beforeEach(async () => { - const entryActionsSpy = { - openShareAccess: vi.fn().mockResolvedValue(undefined), - download: vi.fn(), - }; const hubServiceSpy = { toggleLike: vi.fn().mockReturnValue(of({ liked: true, likeCount: 7 })), }; await TestBed.configureTestingModule({ - imports: [UserDatasetCardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], + imports: [DatasetCardItemComponent, HttpClientTestingModule, BrowserAnimationsModule, RouterTestingModule], providers: [ - { provide: DashboardEntryActionsService, useValue: entryActionsSpy }, + { provide: NzModalService, useValue: { create: vi.fn() } }, + { provide: DatasetService, useValue: { retrieveOwners: vi.fn().mockReturnValue(of([])) } }, + { provide: DownloadService, useValue: { downloadDataset: vi.fn().mockReturnValue(of(new Blob())) } }, { provide: HubService, useValue: hubServiceSpy }, { provide: UserService, useClass: StubUserService }, ...commonTestProviders, @@ -73,7 +73,7 @@ describe("UserDatasetCardItemComponent", () => { schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); - fixture = TestBed.createComponent(UserDatasetCardItemComponent); + fixture = TestBed.createComponent(DatasetCardItemComponent); component = fixture.componentInstance; hubService = TestBed.inject(HubService) as unknown as Mocked; }); diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts similarity index 70% rename from frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts rename to frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts index 3d01e48bced..7d04e056368 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-card-item/user-dataset-card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts @@ -36,21 +36,25 @@ import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; import { NzTooltipModule } from "ng-zorro-antd/tooltip"; import { NzDropdownDirective, NzDropdownMenuComponent } from "ng-zorro-antd/dropdown"; import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu"; -import { DashboardEntry } from "../../../../type/dashboard-entry"; -import { UserAvatarComponent } from "../../user-avatar/user-avatar.component"; -import { DashboardEntryActionsService } from "../../../../service/user/dashboard-entry-actions.service"; -import { HubService } from "../../../../../hub/service/hub.service"; -import { AppSettings } from "../../../../../common/app-setting"; -import { formatSize } from "../../../../../common/util/size-formatter.util"; -import { formatCount, formatRelativeTime } from "../../../../../common/util/format.util"; -import { isDefined } from "../../../../../common/util/predicate"; -import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../../../../../app-routing.constant"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { firstValueFrom } from "rxjs"; +import { DashboardEntry } from "../../../type/dashboard-entry"; +import { UserAvatarComponent } from "../user-avatar/user-avatar.component"; +import { ShareAccessComponent } from "../share-access/share-access.component"; +import { DatasetService } from "../../../service/user/dataset/dataset.service"; +import { DownloadService } from "../../../service/user/download/download.service"; +import { HubService } from "../../../../hub/service/hub.service"; +import { AppSettings } from "../../../../common/app-setting"; +import { formatSize } from "../../../../common/util/size-formatter.util"; +import { formatCount, formatRelativeTime } from "../../../../common/util/format.util"; +import { isDefined } from "../../../../common/util/predicate"; +import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../../../../app-routing.constant"; @UntilDestroy() @Component({ selector: "texera-dataset-card-item", - templateUrl: "./user-dataset-card-item.component.html", - styleUrls: ["./user-dataset-card-item.component.scss"], + templateUrl: "./dataset-card-item.component.html", + styleUrls: ["./dataset-card-item.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ NgIf, @@ -66,24 +70,13 @@ import { DASHBOARD_HUB_DATASET_RESULT_DETAIL, DASHBOARD_USER_DATASET } from "../ UserAvatarComponent, ], }) -export class UserDatasetCardItemComponent implements OnChanges { +export class DatasetCardItemComponent implements OnChanges { @Input() editable = false; @Input() currentUid: number | undefined; + @Input() entry!: DashboardEntry; @Output() deleted = new EventEmitter(); @Output() refresh = new EventEmitter(); - private _entry?: DashboardEntry; - @Input() - get entry(): DashboardEntry { - if (!this._entry) { - throw new Error("entry property must be provided."); - } - return this._entry; - } - set entry(value: DashboardEntry) { - this._entry = value; - } - entryLink: string[] = []; coverImageSrc: string = ""; readonly defaultCover = "assets/card_background.jpg"; @@ -92,7 +85,9 @@ export class UserDatasetCardItemComponent implements OnChanges { isLiked = false; constructor( - private entryActions: DashboardEntryActionsService, + private modalService: NzModalService, + private datasetService: DatasetService, + private downloadService: DownloadService, private hubService: HubService, private cdr: ChangeDetectorRef ) {} @@ -130,12 +125,26 @@ export class UserDatasetCardItemComponent implements OnChanges { } public async onClickOpenShareAccess(): Promise { - const modal = await this.entryActions.openShareAccess(this.entry); - modal?.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => this.refresh.emit()); + if (this.entry.type !== "dataset") return; + const modal = this.modalService.create({ + nzContent: ShareAccessComponent, + nzData: { + writeAccess: this.entry.accessLevel === "WRITE", + type: "dataset", + id: this.entry.id, + allOwners: await firstValueFrom(this.datasetService.retrieveOwners()), + }, + nzFooter: null, + nzTitle: "Share this dataset with others", + nzCentered: true, + nzWidth: "700px", + }); + modal.componentInstance?.refresh.pipe(untilDestroyed(this)).subscribe(() => this.refresh.emit()); } public onClickDownload = (): void => { - this.entryActions.download(this.entry); + if (this.entry.type !== "dataset" || !this.entry.id) return; + this.downloadService.downloadDataset(this.entry.id, this.entry.name).subscribe(); }; toggleLike(): void { diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html index e90931c596e..ff4a31fb7a5 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html @@ -52,18 +52,15 @@ - - + +
- - + [ngTemplateOutlet]="cardTemplate" + [ngTemplateOutletContext]="{ $implicit: entry }"> +
-
- - -
@@ -81,7 +55,6 @@

Datasets

diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss index 3a0cb64df5f..31c95188c44 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.scss @@ -25,31 +25,3 @@ height: 55px; overflow-y: hidden; } - -.view-toggle { - display: inline-flex; - background: #f5f5f5; - border-radius: 6px; - padding: 2px; - margin-left: 8px; - - button { - height: 28px; - padding: 0 10px; - border: none; - background: transparent; - color: #8c8c8c; - border-radius: 4px; - - &:hover { - color: #595959; - background: rgba(255, 255, 255, 0.6); - } - - &.active { - background: #fff; - color: #1f1f1f; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); - } - } -} diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts index 821eeab7aea..165270c0537 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset.component.ts @@ -43,15 +43,6 @@ import { NzIconDirective } from "ng-zorro-antd/icon"; import { FiltersInstructionsComponent } from "../filters-instructions/filters-instructions.component"; import { NzSelectComponent } from "ng-zorro-antd/select"; import { FormsModule } from "@angular/forms"; -import { NzTooltipModule } from "ng-zorro-antd/tooltip"; - -export type ViewMode = "list" | "card"; -export const DATASET_VIEW_MODE_STORAGE_KEY = "texera.dataset.viewMode"; - -/** Read the persisted view mode, falling back to "list" if absent or invalid. */ -export function readStoredDatasetViewMode(): ViewMode { - return localStorage.getItem(DATASET_VIEW_MODE_STORAGE_KEY) === "card" ? "card" : "list"; -} @UntilDestroy() @Component({ @@ -65,7 +56,6 @@ export function readStoredDatasetViewMode(): ViewMode { NzWaveDirective, ɵNzTransitionPatchDirective, NzIconDirective, - NzTooltipModule, FiltersComponent, FiltersInstructionsComponent, NzSelectComponent, @@ -79,13 +69,6 @@ export class UserDatasetComponent implements AfterViewInit { public isLogin = this.userService.isLogin(); public currentUid = this.userService.getCurrentUser()?.uid; public hasMismatch = false; // Display warning when there are mismatched datasets - public viewMode: ViewMode = readStoredDatasetViewMode(); - - setViewMode(mode: ViewMode): void { - if (this.viewMode === mode) return; - this.viewMode = mode; - localStorage.setItem(DATASET_VIEW_MODE_STORAGE_KEY, mode); - } private _searchResultsComponent?: SearchResultsComponent; @ViewChild(SearchResultsComponent) get searchResultsComponent(): SearchResultsComponent { diff --git a/frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts b/frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts deleted file mode 100644 index cdd5cc0230a..00000000000 --- a/frontend/src/app/dashboard/service/user/dashboard-entry-actions.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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 { Injectable } from "@angular/core"; -import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; -import { firstValueFrom } from "rxjs"; -import { ShareAccessComponent } from "../../component/user/share-access/share-access.component"; -import { DashboardEntry } from "../../type/dashboard-entry"; -import { DatasetService } from "./dataset/dataset.service"; -import { DownloadService } from "./download/download.service"; - -/** Dataset actions shared by dashboard list and card views. */ -@Injectable({ providedIn: "root" }) -export class DashboardEntryActionsService { - constructor( - private modalService: NzModalService, - private datasetService: DatasetService, - private downloadService: DownloadService - ) {} - - async openShareAccess(entry: DashboardEntry): Promise | undefined> { - if (entry.type !== "dataset") return undefined; - return this.modalService.create({ - nzContent: ShareAccessComponent, - nzData: { - writeAccess: entry.accessLevel === "WRITE", - type: "dataset", - id: entry.id, - allOwners: await firstValueFrom(this.datasetService.retrieveOwners()), - }, - nzFooter: null, - nzTitle: "Share this dataset with others", - nzCentered: true, - nzWidth: "700px", - }); - } - - download(entry: DashboardEntry): void { - if (entry.type !== "dataset" || !entry.id) return; - this.downloadService.downloadDataset(entry.id, entry.name).subscribe(); - } -} diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html index 1ce36ff5ab3..b910262df7e 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.html @@ -22,14 +22,53 @@
+
+ + +
+ + + + +
+ [currentUid]="this.currentUid" + [viewMode]="searchType === 'dataset' ? viewMode : 'list'" + [cardTemplate]="searchType === 'dataset' ? datasetCardTpl : undefined">
diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss index e172c5b40bd..15c67772751 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.scss @@ -18,3 +18,38 @@ */ @import "../../../dashboard/component/user/search/search.component"; + +.filter { + flex: 1; + display: flex; + align-items: center; + gap: 8px; +} + +.view-toggle { + display: inline-flex; + background: #f5f5f5; + border-radius: 6px; + padding: 2px; + margin-left: auto; + + button { + height: 28px; + padding: 0 10px; + border: none; + background: transparent; + color: #8c8c8c; + border-radius: 4px; + + &:hover { + color: #595959; + background: rgba(255, 255, 255, 0.6); + } + + &.active { + background: #fff; + color: #1f1f1f; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + } + } +} diff --git a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts index 067e9205625..1f69bdcff71 100644 --- a/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts +++ b/frontend/src/app/hub/component/hub-search-result/hub-search-result.component.ts @@ -19,8 +19,16 @@ import { AfterViewInit, Component, Input, OnInit, ViewChild } from "@angular/core"; import { Router } from "@angular/router"; -import { SearchResultsComponent } from "../../../dashboard/component/user/search-results/search-results.component"; +import { NgIf } from "@angular/common"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzTooltipModule } from "ng-zorro-antd/tooltip"; +import { + SearchResultsComponent, + SearchResultsViewMode, +} from "../../../dashboard/component/user/search-results/search-results.component"; import { FiltersComponent } from "../../../dashboard/component/user/filters/filters.component"; +import { DatasetCardItemComponent } from "../../../dashboard/component/user/dataset-card-item/dataset-card-item.component"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { SortMethod } from "../../../dashboard/type/sort-method"; import { UserService } from "../../../common/service/user/user.service"; @@ -30,17 +38,36 @@ import { firstValueFrom } from "rxjs"; import { map } from "rxjs/operators"; import { SortButtonComponent } from "../../../dashboard/component/user/sort-button/sort-button.component"; +const HUB_DATASET_VIEW_MODE_STORAGE_KEY = "texera.hub.dataset.viewMode"; + @UntilDestroy() @Component({ selector: "texera-hub-search", templateUrl: "./hub-search-result.component.html", styleUrls: ["./hub-search-result.component.scss"], - imports: [SortButtonComponent, FiltersComponent, SearchResultsComponent], + imports: [ + NgIf, + NzButtonComponent, + NzIconDirective, + NzTooltipModule, + SortButtonComponent, + FiltersComponent, + SearchResultsComponent, + DatasetCardItemComponent, + ], }) export class HubSearchResultComponent implements OnInit, AfterViewInit { public searchType: "dataset" | "workflow" = "workflow"; public searchKeywords: string[] = []; currentUid = this.userService.getCurrentUser()?.uid; + public viewMode: SearchResultsViewMode = + localStorage.getItem(HUB_DATASET_VIEW_MODE_STORAGE_KEY) === "card" ? "card" : "list"; + + setViewMode(mode: SearchResultsViewMode): void { + if (this.viewMode === mode) return; + this.viewMode = mode; + localStorage.setItem(HUB_DATASET_VIEW_MODE_STORAGE_KEY, mode); + } private isLogin = false; private includePublic = true; From b386fccdd4d6891cb6b1147fd09c79de5c72f3b9 Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Mon, 25 May 2026 18:22:01 -0700 Subject: [PATCH 3/4] update. --- .../user/search-results/search-results.component.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html index ff4a31fb7a5..084ccb0d9fc 100644 --- a/frontend/src/app/dashboard/component/user/search-results/search-results.component.html +++ b/frontend/src/app/dashboard/component/user/search-results/search-results.component.html @@ -22,6 +22,10 @@ [nzBodyStyle]="{ height: '100%'}"> + From 8f3282f900f9dea14664394d888536222b62561c Mon Sep 17 00:00:00 2001 From: Xuan Gu <162244362+xuang7@users.noreply.github.com> Date: Mon, 25 May 2026 18:52:05 -0700 Subject: [PATCH 4/4] lint. --- .../user/dataset-card-item/dataset-card-item.component.spec.ts | 2 -- .../user/dataset-card-item/dataset-card-item.component.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts index bc3ffd11a4f..f7261b9e772 100644 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.spec.ts @@ -18,7 +18,6 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { NO_ERRORS_SCHEMA } from "@angular/core"; import { HttpClientTestingModule } from "@angular/common/http/testing"; import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { RouterTestingModule } from "@angular/router/testing"; @@ -70,7 +69,6 @@ describe("DatasetCardItemComponent", () => { { provide: UserService, useClass: StubUserService }, ...commonTestProviders, ], - schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(DatasetCardItemComponent); diff --git a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts index 7d04e056368..c5d33bbcbe5 100644 --- a/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts +++ b/frontend/src/app/dashboard/component/user/dataset-card-item/dataset-card-item.component.ts @@ -144,7 +144,7 @@ export class DatasetCardItemComponent implements OnChanges { public onClickDownload = (): void => { if (this.entry.type !== "dataset" || !this.entry.id) return; - this.downloadService.downloadDataset(this.entry.id, this.entry.name).subscribe(); + this.downloadService.downloadDataset(this.entry.id, this.entry.name).pipe(untilDestroyed(this)).subscribe(); }; toggleLike(): void {