diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html index f50dcbd0a63..365bdcee51e 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.html @@ -82,375 +82,415 @@

Dataset: {{datasetName}}

[src]="coverImageUrl" alt="Dataset cover" /> - -
- - -
- - - - -
-
-

- - {{ currentDisplayedFileName }} - - - +
+
+ +

Description

+
+ + + + No description provided + +
+
- - - {{ formatSize(currentFileSize) }} - -

+ +
+
+ Created + {{ datasetCreationTime }} +
+
+ Last updated + {{ latestVersionCreationTime }} +
+
+ Versions + {{ versions.length }}
-
- - - - - +
+ Latest version file + {{ latestVersionFileName }} +
+
+ Latest version size + {{ formatSize(latestVersionSize) }}
- +
+
+ + +
+ + + +
+
+

+ + {{ currentDisplayedFileName }} - - - - - -
- -
-
-
- - -
-
Choose a Version:
-
- - - - -
- -
- - Version Size: {{ formatSize(currentDatasetVersionSize) }} -
-
+ + + + - Created at: {{ selectedVersionCreationTime }} -
-
+ class="icon-file"> + {{ formatSize(currentFileSize) }} + +

- - - - - - - - - - +
+ + + + + +
+
+
+ - - - - + + +
+ + +
+ +
+
+
+ + +
+
Choose a Version:
+
+ + + + +
+ +
+ + Version Size: {{ formatSize(currentDatasetVersionSize) }} +
- {{ fileName }} - + *ngIf="selectedVersionCreationTime" + class="version-date"> + + Created at: {{ selectedVersionCreationTime }}
- - +
+
+ + +
+
- + + + + + - -
-
-
- {{ task.status }}: {{ task.filePath }} + + + + +
+ {{ fileName }}
+
+
-
- - - {{ formatSpeed(task.uploadSpeed) }} - - {{ formatTime(task.totalTime ?? 0) }} elapsed, - {{ formatTime(task.estimatedTimeRemaining ?? 0) }} left - + + + +
+
+
+ {{ task.status }}: {{ task.filePath }} + +
+ +
+ + + {{ formatSpeed(task.uploadSpeed) }} - + {{ formatTime(task.totalTime ?? 0) }} elapsed, + {{ formatTime(task.estimatedTimeRemaining ?? 0) }} left + - - Upload time: {{ formatTime(task.totalTime ?? 0) }} - + + Upload time: {{ formatTime(task.totalTime ?? 0) }} + +
-
- + - + - - - - -
+ + + + + - - + + -
-
- - -
-
- +
+
+ + +
+
+ +
-
- - -
- - + + +
+ + +
General
-
Shown on cards and the Metadata tab.
+
Shown on cards.
{ }) ), retrieveDatasetVersionList: vi.fn(() => of([])), + retrieveDatasetLatestVersion: vi.fn(() => + of({ + dvid: 1, + did: 1, + creatorUid: 1, + name: "v1", + versionHash: undefined, + creationTime: undefined, + fileNodes: [], + }) + ), + retrieveDatasetVersionFileTree: vi.fn(() => of({ fileNodes: [], size: 1024 })), getDatasetDiff: vi.fn(() => of([])), createDatasetVersion: vi.fn(() => of({})), deleteDatasetFile: vi.fn(() => of({})), @@ -301,7 +313,15 @@ describe("DatasetDetailComponent upload queue", () => { it("renders the virtualized pending list and re-measures viewports on panel expand", async () => { dropFiles("f1.txt", "f2.txt", "f3.txt", "f4.txt", "f5.txt"); + + // The upload UI lives in the "Versions & Files" tab; nz-tabs does not render a + // tab's content into the DOM until it has been selected at least once. + const tabButtons: NodeListOf = fixture.nativeElement.querySelectorAll(".ant-tabs-tab-btn"); + const versionsTab = Array.from(tabButtons).find(tab => tab.textContent?.includes("Versions & Files")); + expect(versionsTab).toBeTruthy(); + (versionsTab as HTMLElement).click(); fixture.detectChanges(); + // Flush the viewport's init microtask, then render the rows. await Promise.resolve(); fixture.detectChanges(); @@ -423,6 +443,7 @@ describe("DatasetDetailComponent behavior", () => { datasetServiceStub = { getDataset: vi.fn(() => of(makeDashboardDataset())), retrieveDatasetVersionList: vi.fn(() => of([])), + retrieveDatasetLatestVersion: vi.fn(() => of(makeVersion())), getDatasetCoverUrl: vi.fn(() => of({ url: "http://cover" })), retrieveDatasetVersionFileTree: vi.fn(() => of({ fileNodes: [fileLeaf("a.txt", "/root", 1)], size: 1 })), createDatasetVersion: vi.fn(() => of(makeVersion())), @@ -465,6 +486,7 @@ describe("DatasetDetailComponent behavior", () => { expect(datasetServiceStub.getDataset).toHaveBeenCalled(); expect(datasetServiceStub.retrieveDatasetVersionList).toHaveBeenCalled(); + expect(datasetServiceStub.retrieveDatasetLatestVersion).toHaveBeenCalled(); expect(component.likeCount).toBe(7); expect(component.viewCount).toBe(42); expect(hubServiceStub.isLiked).not.toHaveBeenCalled(); @@ -623,6 +645,178 @@ describe("DatasetDetailComponent behavior", () => { expect(datasetServiceStub.retrieveDatasetVersionFileTree).not.toHaveBeenCalled(); }); + + it("does not throw and leaves the displayed file untouched when the version has no files", () => { + datasetServiceStub.retrieveDatasetVersionFileTree.mockReturnValue(of({ fileNodes: [], size: 0 })); + + createComponent(); + component.did = 5; + component.currentDisplayedFileName = "stale.txt"; + component.currentFileSize = 99; + + expect(() => component.onVersionSelected(makeVersion({ dvid: 2 }))).not.toThrow(); + + expect(component.fileTreeNodeList).toEqual([]); + expect(component.currentDatasetVersionSize).toBe(0); + expect(component.currentDisplayedFileName).toBe("stale.txt"); + expect(component.currentFileSize).toBe(99); + }); + }); + + describe("retrieveLatestVersionFile", () => { + it("fetches the latest version independently and sets latestVersionFileName to the first leaf file", () => { + const leaf = fileLeaf("b.txt", "/root", 7); + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ fileNodes: [leaf] }))); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(datasetServiceStub.retrieveDatasetLatestVersion).toHaveBeenCalledWith(5); + expect(component.latestVersionFileName).toBe(getFullPathFromDatasetFileNode(leaf)); + }); + + it("walks nested directories to find the first leaf file", () => { + const leaf = fileLeaf("c.txt", "/root/a", 3); + const tree: DatasetFileNode[] = [{ name: "a", type: "directory", parentDir: "/root", children: [leaf] }]; + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ fileNodes: tree }))); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(component.latestVersionFileName).toBe(getFullPathFromDatasetFileNode(leaf)); + }); + + it("sets latestVersionFileName to an empty string when the latest version has no files", () => { + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion())); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(component.latestVersionFileName).toBe(""); + }); + + it("derives latestVersionCreationTime from the latest version's creationTime", () => { + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue( + of(makeVersion({ dvid: 3, creationTime: CREATION_TS })) + ); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(component.latestVersionCreationTime).toEqual(format(new Date(CREATION_TS), "MM/dd/yyyy HH:mm:ss")); + }); + + it("leaves latestVersionCreationTime empty when the latest version has no creation time", () => { + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ creationTime: undefined }))); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(component.latestVersionCreationTime).toBe(""); + }); + + it("sets latestVersionSize from a file-tree fetch for the latest version's dvid", () => { + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ dvid: 7 }))); + datasetServiceStub.retrieveDatasetVersionFileTree.mockReturnValue(of({ fileNodes: [], size: 4096 })); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(datasetServiceStub.retrieveDatasetVersionFileTree).toHaveBeenCalledWith(5, 7, expect.anything()); + expect(component.latestVersionSize).toBe(4096); + }); + + it("does not fetch a size when the latest version has no dvid", () => { + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ dvid: undefined }))); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(datasetServiceStub.retrieveDatasetVersionFileTree).not.toHaveBeenCalled(); + expect(component.latestVersionSize).toBeUndefined(); + }); + + it("clears a previously fetched latestVersionSize when the latest version has no dvid", () => { + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ dvid: 7 }))); + datasetServiceStub.retrieveDatasetVersionFileTree.mockReturnValue(of({ fileNodes: [], size: 4096 })); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(component.latestVersionSize).toBe(4096); + + // Without a dvid there is no size to show, so the stale one must not linger. + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ dvid: undefined }))); + component.retrieveLatestVersionFile(); + + expect(component.latestVersionSize).toBeUndefined(); + }); + + it("ignores a superseded call's size response that resolves after a newer one", () => { + // The first call's file-tree request never completes before the second starts. + const pendingTree = new Subject<{ fileNodes: DatasetFileNode[]; size: number }>(); + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue(of(makeVersion({ dvid: 7 }))); + datasetServiceStub.retrieveDatasetVersionFileTree.mockReturnValue(pendingTree); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(component.latestVersionSize).toBeUndefined(); + + // A second call supersedes the first and resolves immediately. + datasetServiceStub.retrieveDatasetVersionFileTree.mockReturnValue(of({ fileNodes: [], size: 200 })); + component.retrieveLatestVersionFile(); + + expect(component.latestVersionSize).toBe(200); + + // The superseded response arriving late must not overwrite the fresher size. + pendingTree.next({ fileNodes: [], size: 999 }); + + expect(component.latestVersionSize).toBe(200); + }); + + it("keeps the latest-version facts fixed when a different version is later selected", () => { + datasetServiceStub.retrieveDatasetLatestVersion.mockReturnValue( + of(makeVersion({ dvid: 10, creationTime: CREATION_TS })) + ); + datasetServiceStub.retrieveDatasetVersionFileTree.mockReturnValue(of({ fileNodes: [], size: 500 })); + + createComponent(); + component.did = 5; + component.retrieveLatestVersionFile(); + + expect(component.latestVersionSize).toBe(500); + expect(component.latestVersionCreationTime).toEqual(format(new Date(CREATION_TS), "MM/dd/yyyy HH:mm:ss")); + + // Selecting an older version updates only the selection-scoped values; the + // Data Card's latest-version facts stay pinned to the latest version. + datasetServiceStub.retrieveDatasetVersionFileTree.mockReturnValue(of({ fileNodes: [], size: 99 })); + component.onVersionSelected(makeVersion({ dvid: 9, creationTime: CREATION_TS - 1000 })); + + expect(component.currentDatasetVersionSize).toBe(99); + expect(component.selectedVersionCreationTime).toEqual( + format(new Date(CREATION_TS - 1000), "MM/dd/yyyy HH:mm:ss") + ); + expect(component.latestVersionSize).toBe(500); + expect(component.latestVersionCreationTime).toEqual(format(new Date(CREATION_TS), "MM/dd/yyyy HH:mm:ss")); + }); + + it("does nothing when there is no did", () => { + createComponent(); + component.did = undefined; + component.retrieveLatestVersionFile(); + + expect(datasetServiceStub.retrieveDatasetLatestVersion).not.toHaveBeenCalled(); + }); }); describe("isDownloadAllowed and userHasWriteAccess", () => { @@ -727,6 +921,7 @@ describe("DatasetDetailComponent behavior", () => { expect(component.versionName).toBe(""); expect(component.isCreatingVersion).toBe(false); expect(datasetServiceStub.retrieveDatasetVersionList).toHaveBeenCalled(); + expect(datasetServiceStub.retrieveDatasetLatestVersion).toHaveBeenCalled(); expect(emit).toHaveBeenCalled(); }); @@ -1159,7 +1354,8 @@ describe("DatasetDetailComponent behavior", () => { const tabButtons: NodeListOf = fixture.nativeElement.querySelectorAll(".ant-tabs-tab-btn"); const settingsTab = Array.from(tabButtons).find(tab => tab.textContent?.includes("Settings")); - settingsTab?.click(); + expect(settingsTab).toBeTruthy(); + (settingsTab as HTMLElement).click(); fixture.detectChanges(); return fixture.nativeElement.querySelector('button[title="Delete"]') as HTMLButtonElement; diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts index 3581459670b..142d6a5e63e 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.ts @@ -46,7 +46,7 @@ import { DatasetStagedObject } from "../../../../../common/type/dataset-staged-o import { NzModalService } from "ng-zorro-antd/modal"; import { AdminSettingsService } from "../../../../service/admin/settings/admin-settings.service"; import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; -import { Subscription } from "rxjs"; +import { EMPTY, Subscription } from "rxjs"; import { formatCount, formatSpeed, formatTime, parseIntOrDefault } from "src/app/common/util/format.util"; import { format } from "date-fns"; import { NgIf, NgClass, NgFor } from "@angular/common"; @@ -64,6 +64,7 @@ import { MarkdownDescriptionComponent } from "../../markdown-description/markdow import { NzLayoutComponent, NzContentComponent, NzSiderComponent } from "ng-zorro-antd/layout"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; import { NzEmptyComponent } from "ng-zorro-antd/empty"; +import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs"; import { UserDatasetFileRendererComponent } from "./user-dataset-file-renderer/user-dataset-file-renderer.component"; import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse"; import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select"; @@ -74,7 +75,6 @@ import { NzProgressComponent } from "ng-zorro-antd/progress"; import { UserDatasetStagedObjectsListComponent } from "./user-dataset-staged-objects-list/user-dataset-staged-objects-list.component"; import { NzInputDirective } from "ng-zorro-antd/input"; import { CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport } from "@angular/cdk/scrolling"; -import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs"; export const THROTTLE_TIME_MS = 1000; export const ABORT_RETRY_MAX_ATTEMPTS = 10; @@ -103,6 +103,8 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100; NzContentComponent, NzWaveDirective, NzEmptyComponent, + NzTabsComponent, + NzTabComponent, UserDatasetFileRendererComponent, NzSiderComponent, NzResizableDirective, @@ -121,8 +123,6 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100; CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf, - NzTabsComponent, - NzTabComponent, ], }) export class DatasetDetailComponent implements OnInit { @@ -150,6 +150,14 @@ export class DatasetDetailComponent implements OnInit { public selectedVersion: DatasetVersion | undefined; public fileTreeNodeList: DatasetFileNode[] = []; public selectedVersionCreationTime: string = ""; + // The following three fields describe the latest version for the Data Card, all + // sourced from the single retrieveDatasetLatestVersion response so they stay + // mutually consistent and independent of the version selected in Versions & Files. + public latestVersionCreationTime: string = ""; + public latestVersionFileName: string = ""; + public latestVersionSize: number | undefined; + // Holds the in-flight latest-version fetch so a later call can supersede it. + private latestVersionFileSubscription: Subscription | undefined; public versionCreatorBaseVersion: DatasetVersion | undefined; public isLogin: boolean = this.userService.isLogin(); @@ -241,6 +249,7 @@ export class DatasetDetailComponent implements OnInit { this.did = params["did"]; this.retrieveDatasetInfo(); this.retrieveDatasetVersionList(); + this.retrieveLatestVersionFile(); return this.route.data; // or some other observable }), untilDestroyed(this) @@ -297,6 +306,7 @@ export class DatasetDetailComponent implements OnInit { this.unconfirmedStagedPaths.clear(); this.refreshPendingChanges(); this.retrieveDatasetVersionList(); + this.retrieveLatestVersionFile(); this.userMakeChanges.emit(); }, error: (res: unknown) => { @@ -421,6 +431,43 @@ export class DatasetDetailComponent implements OnInit { } } + // Fetches the latest version independently of the current selection and derives + // the Data Card's latest-version facts from that single response: the file name + // and created date directly, and the total size via a follow-up file-tree fetch + // for the latest version's dvid (mirroring onVersionSelected's size lookup). + retrieveLatestVersionFile() { + if (this.did) { + const did = this.did; + // Both fetches live in one subscription (chained with switchMap rather than + // nested subscribes) so dropping it cancels whichever is still in flight: + // a call started here supersedes any earlier one, and a slow response from + // the superseded call can no longer overwrite fresher facts out of order. + this.latestVersionFileSubscription?.unsubscribe(); + this.latestVersionFileSubscription = this.datasetService + .retrieveDatasetLatestVersion(did) + .pipe( + switchMap(version => { + const firstFile = this.getFirstFileNode(version.fileNodes ?? []); + this.latestVersionFileName = firstFile ? getFullPathFromDatasetFileNode(firstFile) : ""; + this.latestVersionCreationTime = + typeof version.creationTime === "number" + ? format(new Date(version.creationTime), "MM/dd/yyyy HH:mm:ss") + : ""; + if (!version.dvid) { + // Nothing to size: clear rather than keep a previous call's size. + this.latestVersionSize = undefined; + return EMPTY; + } + return this.datasetService.retrieveDatasetVersionFileTree(did, version.dvid, this.isLogin); + }), + untilDestroyed(this) + ) + .subscribe(data => { + this.latestVersionSize = data.size; + }); + } + } + loadFileContent(node: DatasetFileNode) { this.currentDisplayedFileName = getFullPathFromDatasetFileNode(node); this.currentFileSize = node.size; @@ -479,14 +526,23 @@ export class DatasetDetailComponent implements OnInit { const date = new Date(version.creationTime); this.selectedVersionCreationTime = format(date, "MM/dd/yyyy HH:mm:ss"); } - let currentNode = this.fileTreeNodeList[0]; - while (currentNode.type === "directory" && currentNode.children) { - currentNode = currentNode.children[0]; + const currentNode = this.getFirstFileNode(this.fileTreeNodeList); + if (currentNode) { + this.loadFileContent(currentNode); } - this.loadFileContent(currentNode); }); } + // Walk from the first node into directories until reaching a file, returning a + // representative leaf file node (or undefined if the tree has no files). + private getFirstFileNode(nodes: DatasetFileNode[]): DatasetFileNode | undefined { + let currentNode: DatasetFileNode | undefined = nodes[0]; + while (currentNode && currentNode.type === "directory" && currentNode.children) { + currentNode = currentNode.children[0]; + } + return currentNode; + } + onVersionFileTreeNodeSelected(node: DatasetFileNode) { this.loadFileContent(node); }