From 23480a4541f93b3f74612aaec42aca5d28d4ce87 Mon Sep 17 00:00:00 2001 From: Ranjani Veena Belavadi Date: Tue, 28 Jul 2026 13:39:15 -0700 Subject: [PATCH 1/4] refactor(frontend): split dataset detail into Data Card and Versions & Files tabs Adds a Data Card overview tab alongside the existing file browser (now under Versions & Files): a read-only description, an at-a-glance stats list (Created, Last updated, Versions, Latest version file, Selected version size), laid out as two side-by-side cards matching the app's card conventions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dataset-detail.component.html | 726 ++++++++++-------- .../dataset-detail.component.scss | 118 +++ .../dataset-detail.component.ts | 46 +- 3 files changed, 545 insertions(+), 345 deletions(-) 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 3c2a0316170..bf7a3a62ad0 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 @@ -123,370 +123,414 @@

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 }} +
+
+ Selected version size + {{ formatSize(currentDatasetVersionSize) }} +
+
+
-
- + + + +
+ + + +
+
+

+ + {{ currentDisplayedFileName }} - - - - - -
- -
-
-
- - -
-
Choose a Version:
-
- - - - -
- -
- - Version Size: {{ formatSize(currentDatasetVersionSize) }} + + + + + + {{ formatSize(currentFileSize) }} + +

-
- - Created at: {{ selectedVersionCreationTime }} +
+ + + + +
- -
- - - - +
+
+ - - - - - + [dvid]="selectedVersion.dvid" + [filePath]="currentDisplayedFileName" + [fileSize]="currentFileSize" + [isLogin]="isLogin" + class="file-renderer"> + +
+ + +
+ +
+
+
+ + +
+
Choose a Version:
+
+ + + + +
+ +
+ + Version Size: {{ formatSize(currentDatasetVersionSize) }} +
+
+ + Created at: {{ selectedVersionCreationTime }} +
+
+
+ + +
+
+ + + + + + - - - - -
- {{ fileName }} - -
-
-
+ +
+ {{ fileName }} + +
+
+
- + - -
-
-
- {{ task.status }}: {{ task.filePath }} - -
+ (click)="onClickAbortUploadProgress(task)"> + + +
-
- - - {{ formatSpeed(task.uploadSpeed) }} - - {{ formatTime(task.totalTime ?? 0) }} elapsed, - {{ formatTime(task.estimatedTimeRemaining ?? 0) }} left - +
+ + + {{ 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) }} + +
+
+ + - + - - - - - + + + + + - - + + -
-
- - -
-
- -
+
+
+ + +
+
+ +
+
+ +
- - + +
- - + + diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss index d5ef708f889..3147eedaefa 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.scss @@ -347,3 +347,121 @@ nz-select { border-radius: 8px; margin-right: 80px; } + +nz-tabs { + margin-top: 6px; +} + +::ng-deep .ant-tabs-nav { + padding-left: 16px; +} + +.versions-tab-content { + height: 100%; + overflow-y: auto; + padding-bottom: 24px; +} + +.data-card-tab-content { + height: 100%; + overflow-y: auto; + padding-bottom: 24px; +} + +// Two side-by-side cards filling the full tab width: a wide description card +// and a narrower stats card. The flex grow ratio is the single source of +// truth for their proportions (basis 0), so no width/max-width competes. +.data-card-columns { + display: flex; + align-items: flex-start; + gap: 20px; + margin-top: 24px; + width: 100%; + // Match the tab bar's left inset (.ant-tabs-nav padding-left) so the cards + // line up with the tab labels above, not the page's outer edge. + padding-left: 16px; +} + +.data-card { + border-radius: 8px; + border: 1px solid #d9d9d9; + // Let each card shrink to its flex share regardless of content width; without + // this the default min-width:auto keeps them from shrinking, skewing the + // ratio and leaving odd empty gaps. + min-width: 0; +} + +// Left card takes ~3/4 of the row, stats card ~1/4 — grow ratio with basis 0 +// so the split is exact and content-independent. +.data-card-main { + flex: 3 1 0; +} + +.data-card-details { + flex: 1 1 0; +} + +// Stack the two cards full-width on narrow viewports. +@media (max-width: 768px) { + .data-card-columns { + flex-direction: column; + align-items: stretch; + } +} + +// Matches the app's standard card-section heading convention (e.g. the +// Settings tab's "General" card title): bold, dark, slightly larger than +// body text, with a clear gap before the content below. +.data-card-heading { + font-size: 18px; + font-weight: 600; + color: rgba(0, 0, 0, 0.85); + margin: 0 0 20px; +} + +// Plain text, no box/border styling — larger size keeps it readable as prose. +.data-card-description { + font-size: 16px; + line-height: 1.6; + margin-bottom: 32px; +} + +// Empty state: muted grey italic so it reads as "nothing here yet", not broken. +.empty-description { + color: rgba(0, 0, 0, 0.4); + font-style: italic; +} + +// Stats as a full-width vertical list, one fact per row, avoiding the awkward +// wrapping of the previous cramped horizontal layout. +.data-card-stats { + display: flex; + flex-direction: column; +} + +.stat-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid #f0f0f0; + + &:last-child { + border-bottom: none; + } +} + +.stat-label { + font-size: 13px; + font-weight: 600; + color: rgba(0, 0, 0, 0.85); + flex-shrink: 0; +} + +.stat-value { + font-weight: 400; + color: rgba(0, 0, 0, 0.45); + text-align: right; + word-break: break-word; +} 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 bdef5e01681..d7b0b699d93 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 @@ -58,6 +58,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"; @@ -96,6 +97,8 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100; NzContentComponent, NzWaveDirective, NzEmptyComponent, + NzTabsComponent, + NzTabComponent, UserDatasetFileRendererComponent, NzSiderComponent, NzResizableDirective, @@ -140,6 +143,12 @@ export class DatasetDetailComponent implements OnInit { public selectedVersion: DatasetVersion | undefined; public fileTreeNodeList: DatasetFileNode[] = []; public selectedVersionCreationTime: string = ""; + // Latest version's created date, derived from versions[0] (the backend-guaranteed + // latest) so it stays correct regardless of which version is selected below. + public latestVersionCreationTime: string = ""; + // A representative file name from the latest version, fetched independently of + // the current selection via retrieveDatasetLatestVersion. + public latestVersionFileName: string = ""; public versionCreatorBaseVersion: DatasetVersion | undefined; public isLogin: boolean = this.userService.isLogin(); @@ -230,6 +239,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) @@ -286,6 +296,7 @@ export class DatasetDetailComponent implements OnInit { this.unconfirmedStagedPaths.clear(); this.refreshPendingChanges(); this.retrieveDatasetVersionList(); + this.retrieveLatestVersionFile(); this.userMakeChanges.emit(); }, error: (res: unknown) => { @@ -402,6 +413,10 @@ export class DatasetDetailComponent implements OnInit { // by default, the selected version is the 1st element in the retrieved list // which is guaranteed(by the backend) to be the latest created version. if (this.versions.length > 0) { + const latestVersion = this.versions[0]; + if (typeof latestVersion.creationTime === "number") { + this.latestVersionCreationTime = format(new Date(latestVersion.creationTime), "MM/dd/yyyy HH:mm:ss"); + } this.selectedVersion = this.versions[0]; this.onVersionSelected(this.selectedVersion); } @@ -409,6 +424,20 @@ export class DatasetDetailComponent implements OnInit { } } + // Fetches the latest version (with its file tree) independently of the current + // selection, then derives a representative file name for the Data Card. + retrieveLatestVersionFile() { + if (this.did) { + this.datasetService + .retrieveDatasetLatestVersion(this.did) + .pipe(untilDestroyed(this)) + .subscribe(version => { + const firstFile = this.getFirstFileNode(version.fileNodes ?? []); + this.latestVersionFileName = firstFile ? getFullPathFromDatasetFileNode(firstFile) : ""; + }); + } + } + loadFileContent(node: DatasetFileNode) { this.currentDisplayedFileName = getFullPathFromDatasetFileNode(node); this.currentFileSize = node.size; @@ -467,14 +496,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); } From 61a1288217a2a2577aac61c0eae67b211f5c4ebd Mon Sep 17 00:00:00 2001 From: Ranjani Veena Belavadi Date: Wed, 29 Jul 2026 10:20:56 -0700 Subject: [PATCH 2/4] test(frontend): cover latestVersionCreationTime derivation and retrieveLatestVersionFile --- .../dataset-detail.component.spec.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts index 5f2926f7d52..232359a8853 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts @@ -484,6 +484,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(); @@ -616,6 +617,37 @@ describe("DatasetDetailComponent behavior", () => { expect(component.selectedVersion).toBeUndefined(); expect(spy).not.toHaveBeenCalled(); }); + + it("derives latestVersionCreationTime from versions[0], independent of which version is later selected", () => { + const latest = makeVersion({ dvid: 10, name: "v10", creationTime: CREATION_TS }); + const older = makeVersion({ dvid: 9, name: "v9", creationTime: CREATION_TS - 1000 }); + datasetServiceStub.retrieveDatasetVersionList.mockReturnValue(of([latest, older])); + + createComponent(); + component.did = 5; + component.retrieveDatasetVersionList(); + + expect(component.latestVersionCreationTime).toEqual(format(new Date(CREATION_TS), "MM/dd/yyyy HH:mm:ss")); + + // Selecting an older version (as the user can from the Versions & Files tab) + // must not change the Data Card's "Last updated" stat. + component.onVersionSelected(older); + + expect(component.latestVersionCreationTime).toEqual(format(new Date(CREATION_TS), "MM/dd/yyyy HH:mm:ss")); + expect(component.selectedVersionCreationTime).toEqual( + format(new Date(CREATION_TS - 1000), "MM/dd/yyyy HH:mm:ss") + ); + }); + + it("leaves latestVersionCreationTime unset when the latest version has no creation time", () => { + datasetServiceStub.retrieveDatasetVersionList.mockReturnValue(of([makeVersion({ creationTime: undefined })])); + + createComponent(); + component.did = 5; + component.retrieveDatasetVersionList(); + + expect(component.latestVersionCreationTime).toBe(""); + }); }); describe("onVersionSelected", () => { @@ -642,6 +674,66 @@ 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("does nothing when there is no did", () => { + createComponent(); + component.did = undefined; + component.retrieveLatestVersionFile(); + + expect(datasetServiceStub.retrieveDatasetLatestVersion).not.toHaveBeenCalled(); + }); }); describe("isDownloadAllowed and userHasWriteAccess", () => { @@ -746,6 +838,7 @@ describe("DatasetDetailComponent behavior", () => { expect(component.versionName).toBe(""); expect(component.isCreatingVersion).toBe(false); expect(datasetServiceStub.retrieveDatasetVersionList).toHaveBeenCalled(); + expect(datasetServiceStub.retrieveDatasetLatestVersion).toHaveBeenCalled(); expect(emit).toHaveBeenCalled(); }); From 8258506cd132a8eee75fa19685358cb6113da838 Mon Sep 17 00:00:00 2001 From: Ranjani Veena Belavadi Date: Wed, 29 Jul 2026 14:24:10 -0700 Subject: [PATCH 3/4] fix(frontend): derive latest version size and last-updated from a single source --- .../dataset-detail.component.html | 6 +- .../dataset-detail.component.spec.ts | 102 ++++++++++++------ .../dataset-detail.component.ts | 33 ++++-- 3 files changed, 96 insertions(+), 45 deletions(-) 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 f168bf23db6..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 @@ -122,8 +122,8 @@

Description

{{ latestVersionFileName }}
- Selected version size - {{ formatSize(currentDatasetVersionSize) }} + Latest version size + {{ formatSize(latestVersionSize) }}
@@ -521,7 +521,7 @@

General

-
Shown on cards and the Metadata tab.
+
Shown on cards.
{ expect(component.selectedVersion).toBeUndefined(); expect(spy).not.toHaveBeenCalled(); }); - - it("derives latestVersionCreationTime from versions[0], independent of which version is later selected", () => { - const latest = makeVersion({ dvid: 10, name: "v10", creationTime: CREATION_TS }); - const older = makeVersion({ dvid: 9, name: "v9", creationTime: CREATION_TS - 1000 }); - datasetServiceStub.retrieveDatasetVersionList.mockReturnValue(of([latest, older])); - - createComponent(); - component.did = 5; - component.retrieveDatasetVersionList(); - - expect(component.latestVersionCreationTime).toEqual(format(new Date(CREATION_TS), "MM/dd/yyyy HH:mm:ss")); - - // Selecting an older version (as the user can from the Versions & Files tab) - // must not change the Data Card's "Last updated" stat. - component.onVersionSelected(older); - - expect(component.latestVersionCreationTime).toEqual(format(new Date(CREATION_TS), "MM/dd/yyyy HH:mm:ss")); - expect(component.selectedVersionCreationTime).toEqual( - format(new Date(CREATION_TS - 1000), "MM/dd/yyyy HH:mm:ss") - ); - }); - - it("leaves latestVersionCreationTime unset when the latest version has no creation time", () => { - datasetServiceStub.retrieveDatasetVersionList.mockReturnValue(of([makeVersion({ creationTime: undefined })])); - - createComponent(); - component.did = 5; - component.retrieveDatasetVersionList(); - - expect(component.latestVersionCreationTime).toBe(""); - }); }); describe("onVersionSelected", () => { @@ -727,6 +696,77 @@ describe("DatasetDetailComponent behavior", () => { 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("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; 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 1dadb228528..3aaeb0f399d 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 @@ -150,12 +150,12 @@ export class DatasetDetailComponent implements OnInit { public selectedVersion: DatasetVersion | undefined; public fileTreeNodeList: DatasetFileNode[] = []; public selectedVersionCreationTime: string = ""; - // Latest version's created date, derived from versions[0] (the backend-guaranteed - // latest) so it stays correct regardless of which version is selected below. + // 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 = ""; - // A representative file name from the latest version, fetched independently of - // the current selection via retrieveDatasetLatestVersion. public latestVersionFileName: string = ""; + public latestVersionSize: number | undefined; public versionCreatorBaseVersion: DatasetVersion | undefined; public isLogin: boolean = this.userService.isLogin(); @@ -422,10 +422,6 @@ export class DatasetDetailComponent implements OnInit { // by default, the selected version is the 1st element in the retrieved list // which is guaranteed(by the backend) to be the latest created version. if (this.versions.length > 0) { - const latestVersion = this.versions[0]; - if (typeof latestVersion.creationTime === "number") { - this.latestVersionCreationTime = format(new Date(latestVersion.creationTime), "MM/dd/yyyy HH:mm:ss"); - } this.selectedVersion = this.versions[0]; this.onVersionSelected(this.selectedVersion); } @@ -433,16 +429,31 @@ export class DatasetDetailComponent implements OnInit { } } - // Fetches the latest version (with its file tree) independently of the current - // selection, then derives a representative file name for the Data Card. + // 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; this.datasetService - .retrieveDatasetLatestVersion(this.did) + .retrieveDatasetLatestVersion(did) .pipe(untilDestroyed(this)) .subscribe(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) { + this.datasetService + .retrieveDatasetVersionFileTree(did, version.dvid, this.isLogin) + .pipe(untilDestroyed(this)) + .subscribe(data => { + this.latestVersionSize = data.size; + }); + } }); } } From 1557461970408978f99ac9ba28e839a5509ca26c Mon Sep 17 00:00:00 2001 From: Ranjani Veena Belavadi Date: Thu, 30 Jul 2026 12:57:51 -0700 Subject: [PATCH 4/4] fix(frontend): address Copilot review feedback (race condition, missing stub, test assertion) --- .../dataset-detail.component.spec.ts | 48 ++++++++++++++++++- .../dataset-detail.component.ts | 46 +++++++++++------- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts index d580d774ef7..d7faeaa9200 100644 --- a/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-dataset/user-dataset-explorer/dataset-detail.component.spec.ts @@ -101,6 +101,7 @@ describe("DatasetDetailComponent upload queue", () => { fileNodes: [], }) ), + retrieveDatasetVersionFileTree: vi.fn(() => of({ fileNodes: [], size: 1024 })), getDatasetDiff: vi.fn(() => of([])), createDatasetVersion: vi.fn(() => of({})), deleteDatasetFile: vi.fn(() => of({})), @@ -317,7 +318,8 @@ describe("DatasetDetailComponent upload queue", () => { // 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")); - versionsTab?.click(); + expect(versionsTab).toBeTruthy(); + (versionsTab as HTMLElement).click(); fixture.detectChanges(); // Flush the viewport's init microtask, then render the rows. @@ -741,6 +743,47 @@ describe("DatasetDetailComponent behavior", () => { 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 })) @@ -1311,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 3aaeb0f399d..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"; @@ -156,6 +156,8 @@ export class DatasetDetailComponent implements OnInit { 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(); @@ -436,24 +438,32 @@ export class DatasetDetailComponent implements OnInit { retrieveLatestVersionFile() { if (this.did) { const did = this.did; - this.datasetService + // 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(untilDestroyed(this)) - .subscribe(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) { - this.datasetService - .retrieveDatasetVersionFileTree(did, version.dvid, this.isLogin) - .pipe(untilDestroyed(this)) - .subscribe(data => { - this.latestVersionSize = data.size; - }); - } + .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; }); } }