This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7043-084309c355979548ba76b97ba8ef6753bd256ba7 in repository https://gitbox.apache.org/repos/asf/texera.git
commit ac68ea471bc8367d6142ddc9f4ae7df6f1aa3d06 Author: Ranjani Veena Belavadi <[email protected]> AuthorDate: Thu Jul 30 15:02:13 2026 -0700 refactor(frontend): extract Data Card and Versions & Files tabs (#7043) ### What changes were proposed in this PR? Extracts the remaining content on the dataset detail page into two new tabs: Data Card and Versions & Files. Part of #6394; second of two sub-issues (#6395, Settings, already merged). - **Versions & Files**: pure relocation. The existing file browser, version selector, file preview, and upload widget move here unchanged. - **Data Card**: new read-only overview, two cards: - Description (moved out of the page header, which previously duplicated it, the header now shows name/badges/counts only), using the existing `texera-markdown-description` component's built-in "View more" collapse for long descriptions. - Key facts panel: Created date, Last updated (latest version's creation date, independent of whatever's selected in Versions & Files), Latest version file name, Latest version size, and Versions count. Before: two tabs- Data Card (holding everything: file browser, version history, uploads, plus a duplicated description in the header) and Settings. <img width="1915" height="1018" alt="settings_tab_recording" src="https://github.com/user-attachments/assets/eee5240a-9bc1-493c-8fda-02e2eb2ba759" /> After: three tabs- Data Card (read-only overview only), Versions & Files (file/version management, split out of the old Data Card tab), and Settings (unchanged). <img width="1918" height="1019" alt="dataset_detail_tab_layout" src="https://github.com/user-attachments/assets/7f4e4f58-336b-4a71-8812-28d79dac2489" /> ### Any related issues, documentation, discussions? Closes #6942 ### How was this PR tested? Added 7 unit tests to `dataset-detail.component.spec.ts` covering the new Data Card logic: - "Last updated" derives from the latest version, independent of whichever version is selected in Versions & Files. - `retrieveLatestVersionFile()` correctly resolves the latest version's file name, including nested paths and the no-files case. - Selecting a version with an empty file tree no longer throws. Manually tested on UI: - Versions & Files retains all existing functionality (file browsing, version switching, file preview, upload) unchanged. - Description renders correctly in Data Card, including markdown formatting and the "View more" collapse on long descriptions. - Settings tab (merged separately in #6395) is unaffected. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]> Co-authored-by: Xuan Gu <[email protected]> --- .../dataset-detail.component.html | 708 +++++++++++---------- .../dataset-detail.component.scss | 110 ++++ .../dataset-detail.component.spec.ts | 198 +++++- .../dataset-detail.component.ts | 72 ++- 4 files changed, 745 insertions(+), 343 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 f50dcbd0a6..365bdcee51 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 @@ [src]="coverImageUrl" alt="Dataset cover" /> </div> - - <div class="description-section"> - <texera-markdown-description - [description]="datasetDescription" - [editable]="false" - [enableViewMore]="true"> - </texera-markdown-description> - </div> </nz-card> </div> - <nz-tabs> <nz-tab nzTitle="Data Card"> - <nz-layout> - <nz-content - [ngClass]="{'grayed-out': false, 'disabled-click': false}" - style="background-color: white"> - <nz-card> - <div style="display: flex; justify-content: space-between; align-items: center"> - <div class="file-info"> - <h3 class="file-title"> - <span class="file-title-main"> - <b>{{ currentDisplayedFileName }}</b> - - <button - nz-button - nzType="text" - nzSize="small" - class="copy-path-btn" - nz-tooltip - nzTooltipTitle="Copy file path" - *ngIf="currentDisplayedFileName" - (click)="copyCurrentFilePath()"> - <i - nz-icon - nzType="copy" - nzTheme="twotone"> - </i> - </button> - </span> + <div class="data-card-tab-content"> + <div class="data-card-columns"> + <nz-card class="data-card data-card-main"> + <h3 class="data-card-heading">Description</h3> + <div class="data-card-description"> + <texera-markdown-description + *ngIf="datasetDescription; else noDescription" + [description]="datasetDescription" + [editable]="false" + [enableViewMore]="true"> + </texera-markdown-description> + <ng-template #noDescription> + <span class="empty-description">No description provided</span> + </ng-template> + </div> + </nz-card> - <span - *ngIf="currentFileSize" - class="file-size"> - <i - nz-icon - nzType="file" - nzTheme="outline" - class="icon-file"></i> - {{ formatSize(currentFileSize) }} - </span> - </h3> + <nz-card class="data-card data-card-details"> + <div class="data-card-stats"> + <div class="stat-row"> + <span class="stat-label">Created</span> + <span class="stat-value">{{ datasetCreationTime }}</span> + </div> + <div class="stat-row"> + <span class="stat-label">Last updated</span> + <span class="stat-value">{{ latestVersionCreationTime }}</span> + </div> + <div class="stat-row"> + <span class="stat-label">Versions</span> + <span class="stat-value">{{ versions.length }}</span> </div> - <div style="display: flex"> - <button - nz-button - *ngIf="selectedVersion" - nz-tooltip="Download the file" - [disabled]="!isLogin || !isDownloadAllowed()" - (click)="onClickDownloadCurrentFile()"> - <i - nz-icon - nzTheme="outline" - nzType="download"> - </i> - </button> - <button - nz-button - *ngIf="!isMaximized && selectedVersion" - nz-tooltip="Maximize View" - (click)="onClickScaleTheView()"> - <i - nz-icon - nzTheme="outline" - nzType="expand"> - </i> - </button> - <button - nz-button - *ngIf="isMaximized && selectedVersion" - nz-tooltip="Minimize View" - (click)="onClickScaleTheView()"> - <i - nz-icon - nzTheme="outline" - nzType="compress"> - </i> - </button> - <button - *ngIf="!isRightBarCollapsed" - nz-button - nz-tooltip="Hide the right bar" - (click)="onClickHideRightBar()"> - <i - nz-icon - nzTheme="outline" - nzType="right"> - </i> - </button> - <button - *ngIf="isRightBarCollapsed" - nz-button - nz-tooltip="Show Tree" - (click)="onClickHideRightBar()"> - <i - nz-icon - nzTheme="outline" - nzType="left"> - </i> - </button> + <div class="stat-row"> + <span class="stat-label">Latest version file</span> + <span class="stat-value">{{ latestVersionFileName }}</span> + </div> + <div class="stat-row"> + <span class="stat-label">Latest version size</span> + <span class="stat-value">{{ formatSize(latestVersionSize) }}</span> </div> </div> </nz-card> - <nz-empty - class="empty-version-indicator" - *ngIf="!selectedVersion" - nzNotFoundContent="No version is selected"></nz-empty> + </div> + </div> + </nz-tab> + <nz-tab nzTitle="Versions & Files"> + <div class="versions-tab-content"> + <nz-layout> + <nz-content + [ngClass]="{'grayed-out': false, 'disabled-click': false}" + style="background-color: white"> + <nz-card> + <div style="display: flex; justify-content: space-between; align-items: center"> + <div class="file-info"> + <h3 class="file-title"> + <span class="file-title-main"> + <b>{{ currentDisplayedFileName }}</b> - <texera-user-dataset-file-renderer - *ngIf="selectedVersion" - [isMaximized]="isMaximized" - [did]="did" - [dvid]="selectedVersion.dvid" - [filePath]="currentDisplayedFileName" - [fileSize]="currentFileSize" - [isLogin]="isLogin" - class="file-renderer"> - </texera-user-dataset-file-renderer> - </nz-content> - <nz-sider - *ngIf="!isRightBarCollapsed" - nzTheme="light" - [nzWidth]="siderWidth" - nz-resizable - [nzMinWidth]="MIN_SIDER_WIDTH" - [nzMaxWidth]="MAX_SIDER_WIDTH" - (nzResize)="onSideResize($event)"> - <nz-resize-handle nzDirection="left"> - <div class="sider-resize-line"> - <i - class="sider-resize-handle" - nz-icon - nzType="more" - nzTheme="outline"></i> - </div> - </nz-resize-handle> - <div class="right-sider"> - <nz-collapse nzGhost> - <nz-collapse-panel - nzHeader="Current Versions" - nzActive="true"> - <div class="version-storage"> - <h6 style="font-weight: lighter; font-size: 0.9em">Choose a Version:</h6> - <div class="select-and-button-container"> - <nz-select - nzShowSearch - nzAllowClear - nzPlaceHolder="Select a version" - (ngModelChange)="onVersionSelected($event)" - [(ngModel)]="selectedVersion"> - <nz-option - *ngFor="let version of versions" - [nzValue]="version" - [nzLabel]="version.name"></nz-option> - </nz-select> - <button - nz-button - nz-tooltip="Download Dataset" - (click)="onClickDownloadVersionAsZip()" - *ngIf="selectedVersion" - [disabled]="!isLogin || !isDownloadAllowed()" - class="spaced-button"> - <i - nz-icon - nzType="download" - nzTheme="outline"></i> - </button> - </div> - <ng-container *ngIf="selectedVersion"> - <div class="version-size"> - <i - nz-icon - nzType="database" - nzTheme="outline" - class="icon-database"></i> - Version Size: {{ formatSize(currentDatasetVersionSize) }} - </div> - <div - *ngIf="selectedVersionCreationTime" - class="version-date"> + <button + nz-button + nzType="text" + nzSize="small" + class="copy-path-btn" + nz-tooltip + nzTooltipTitle="Copy file path" + *ngIf="currentDisplayedFileName" + (click)="copyCurrentFilePath()"> + <i + nz-icon + nzType="copy" + nzTheme="twotone"> + </i> + </button> + </span> + + <span + *ngIf="currentFileSize" + class="file-size"> <i nz-icon - nzType="calendar" + nzType="file" nzTheme="outline" - class="icon-database"></i> - Created at: {{ selectedVersionCreationTime }} - </div> - </ng-container> + class="icon-file"></i> + {{ formatSize(currentFileSize) }} + </span> + </h3> </div> - <texera-user-dataset-version-filetree - [fileTreeNodes]="fileTreeNodeList" - [isTreeNodeDeletable]="true" - (selectedTreeNode)="onVersionFileTreeNodeSelected($event)" - (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)" - (setCoverImage)="onSetCoverImage($event)"> - </texera-user-dataset-version-filetree> - </nz-collapse-panel> - </nz-collapse> - - <nz-divider></nz-divider> - <nz-collapse - *ngIf="userDatasetAccessLevel === 'WRITE'" - nzGhost> - <nz-collapse-panel - nzActive="true" - nzHeader="Create New Version"> - <texera-user-files-uploader - [did]="did" - [ownerEmail]="ownerEmail" - [datasetName]="datasetName" - (uploadedFiles)="onNewUploadFilesChanged($event)"> - </texera-user-files-uploader> + <div style="display: flex"> + <button + nz-button + *ngIf="selectedVersion" + nz-tooltip="Download the file" + [disabled]="!isLogin || !isDownloadAllowed()" + (click)="onClickDownloadCurrentFile()"> + <i + nz-icon + nzTheme="outline" + nzType="download"> + </i> + </button> + <button + nz-button + *ngIf="!isMaximized && selectedVersion" + nz-tooltip="Maximize View" + (click)="onClickScaleTheView()"> + <i + nz-icon + nzTheme="outline" + nzType="expand"> + </i> + </button> + <button + nz-button + *ngIf="isMaximized && selectedVersion" + nz-tooltip="Minimize View" + (click)="onClickScaleTheView()"> + <i + nz-icon + nzTheme="outline" + nzType="compress"> + </i> + </button> + <button + *ngIf="!isRightBarCollapsed" + nz-button + nz-tooltip="Hide the right bar" + (click)="onClickHideRightBar()"> + <i + nz-icon + nzTheme="outline" + nzType="right"> + </i> + </button> + <button + *ngIf="isRightBarCollapsed" + nz-button + nz-tooltip="Show Tree" + (click)="onClickHideRightBar()"> + <i + nz-icon + nzTheme="outline" + nzType="left"> + </i> + </button> + </div> + </div> + </nz-card> + <nz-empty + class="empty-version-indicator" + *ngIf="!selectedVersion" + nzNotFoundContent="No version is selected"></nz-empty> - <nz-collapse - nzGhost - class="upload-status-panels"> - <nz-collapse-panel - *ngIf="queuedCount > 0" - [nzHeader]="'Pending: ' + queuedCount + ' file(s)'" - (nzActiveChange)="onPendingPanelActiveChange($event)"> - <!-- Virtualized so only visible rows are in the DOM (#5586). The viewport - measures height 0 while the panel is collapsed, so it is re-measured - on expand. --> - <cdk-virtual-scroll-viewport - class="upload-progress-wrapper-pending" - [itemSize]="PENDING_ROW_HEIGHT_PX" - [minBufferPx]="PENDING_LIST_MAX_HEIGHT_PX" - [maxBufferPx]="2 * PENDING_LIST_MAX_HEIGHT_PX" - [style.height.px]="pendingListHeightPx"> + <texera-user-dataset-file-renderer + *ngIf="selectedVersion" + [isMaximized]="isMaximized" + [did]="did" + [dvid]="selectedVersion.dvid" + [filePath]="currentDisplayedFileName" + [fileSize]="currentFileSize" + [isLogin]="isLogin" + class="file-renderer"> + </texera-user-dataset-file-renderer> + </nz-content> + <nz-sider + *ngIf="!isRightBarCollapsed" + nzTheme="light" + [nzWidth]="siderWidth" + nz-resizable + [nzMinWidth]="MIN_SIDER_WIDTH" + [nzMaxWidth]="MAX_SIDER_WIDTH" + (nzResize)="onSideResize($event)"> + <nz-resize-handle nzDirection="left"> + <div class="sider-resize-line"> + <i + class="sider-resize-handle" + nz-icon + nzType="more" + nzTheme="outline"></i> + </div> + </nz-resize-handle> + <div class="right-sider"> + <nz-collapse nzGhost> + <nz-collapse-panel + nzHeader="Current Versions" + nzActive="true"> + <div class="version-storage"> + <h6 style="font-weight: lighter; font-size: 0.9em">Choose a Version:</h6> + <div class="select-and-button-container"> + <nz-select + nzShowSearch + nzAllowClear + nzPlaceHolder="Select a version" + (ngModelChange)="onVersionSelected($event)" + [(ngModel)]="selectedVersion"> + <nz-option + *ngFor="let version of versions" + [nzValue]="version" + [nzLabel]="version.name"></nz-option> + </nz-select> + <button + nz-button + nz-tooltip="Download Dataset" + (click)="onClickDownloadVersionAsZip()" + *ngIf="selectedVersion" + [disabled]="!isLogin || !isDownloadAllowed()" + class="spaced-button"> + <i + nz-icon + nzType="download" + nzTheme="outline"></i> + </button> + </div> + <ng-container *ngIf="selectedVersion"> + <div class="version-size"> + <i + nz-icon + nzType="database" + nzTheme="outline" + class="icon-database"></i> + Version Size: {{ formatSize(currentDatasetVersionSize) }} + </div> <div - class="pending-file-row" - *cdkVirtualFor="let fileName of queuedFileNames; trackBy: trackByPendingFile"> - <span - class="pending-file-name" - nz-tooltip - [nzTooltipTitle]="fileName" - >{{ fileName }}</span - > - <button - nz-button - nzType="text" - nzShape="circle" - nz-tooltip - [nzTooltipTitle]="'Remove from queue'" - (click)="cancelExistingUpload(fileName)"> - <span - nz-icon - nzType="close" - nzTheme="outline"></span> - </button> + *ngIf="selectedVersionCreationTime" + class="version-date"> + <i + nz-icon + nzType="calendar" + nzTheme="outline" + class="icon-database"></i> + Created at: {{ selectedVersionCreationTime }} </div> - </cdk-virtual-scroll-viewport> - </nz-collapse-panel> + </ng-container> + </div> + <texera-user-dataset-version-filetree + [fileTreeNodes]="fileTreeNodeList" + [isTreeNodeDeletable]="true" + (selectedTreeNode)="onVersionFileTreeNodeSelected($event)" + (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)" + (setCoverImage)="onSetCoverImage($event)"> + </texera-user-dataset-version-filetree> + </nz-collapse-panel> + </nz-collapse> - <nz-divider - class="section-divider" - *ngIf="queuedCount > 0"></nz-divider> + <nz-divider></nz-divider> + <nz-collapse + *ngIf="userDatasetAccessLevel === 'WRITE'" + nzGhost> + <nz-collapse-panel + nzActive="true" + nzHeader="Create New Version"> + <texera-user-files-uploader + [did]="did" + [ownerEmail]="ownerEmail" + [datasetName]="datasetName" + (uploadedFiles)="onNewUploadFilesChanged($event)"> + </texera-user-files-uploader> - <nz-collapse-panel - *ngIf="activeCount > 0" - [nzHeader]="'Uploading: ' + activeCount + ' file(s)'"> - <div class="upload-progress-wrapper"> - <div *ngFor="let task of uploadTasks; trackBy: trackByTask"> - <div class="progress-header"> - <span><b>{{ task.status }}</b>: {{ task.filePath }}</span> + <nz-collapse + nzGhost + class="upload-status-panels"> + <nz-collapse-panel + *ngIf="queuedCount > 0" + [nzHeader]="'Pending: ' + queuedCount + ' file(s)'" + (nzActiveChange)="onPendingPanelActiveChange($event)"> + <!-- Virtualized so only visible rows are in the DOM (#5586). The viewport + measures height 0 while the panel is collapsed, so it is re-measured + on expand. --> + <cdk-virtual-scroll-viewport + class="upload-progress-wrapper-pending" + [itemSize]="PENDING_ROW_HEIGHT_PX" + [minBufferPx]="PENDING_LIST_MAX_HEIGHT_PX" + [maxBufferPx]="2 * PENDING_LIST_MAX_HEIGHT_PX" + [style.height.px]="pendingListHeightPx"> + <div + class="pending-file-row" + *cdkVirtualFor="let fileName of queuedFileNames; trackBy: trackByPendingFile"> + <span + class="pending-file-name" + nz-tooltip + [nzTooltipTitle]="fileName" + >{{ fileName }}</span + > <button nz-button nzType="text" nzShape="circle" - [nz-tooltip]=" - (task.status === 'aborted' || task.status === 'finished') - ? 'Close' - : 'Cancel the upload' - " - (click)="onClickAbortUploadProgress(task)"> - <i + nz-tooltip + [nzTooltipTitle]="'Remove from queue'" + (click)="cancelExistingUpload(fileName)"> + <span nz-icon nzType="close" - nzTheme="outline"></i> + nzTheme="outline"></span> </button> </div> + </cdk-virtual-scroll-viewport> + </nz-collapse-panel> - <div - class="upload-stats" - *ngIf="task.status !== 'initializing'"> - <nz-progress - [nzPercent]="task.percentage" - [nzStatus]="getUploadStatus(task.status)"></nz-progress> - <nz-tag - *ngIf="task.status === 'uploading'" - [nzColor]="'blue'"> - <span class="fixed-width-speed">{{ formatSpeed(task.uploadSpeed) }}</span> - - <span class="fixed-width-time">{{ formatTime(task.totalTime ?? 0) }}</span> elapsed, - <span class="fixed-width-time">{{ formatTime(task.estimatedTimeRemaining ?? 0) }} left</span> - </nz-tag> + <nz-divider + class="section-divider" + *ngIf="queuedCount > 0"></nz-divider> + + <nz-collapse-panel + *ngIf="activeCount > 0" + [nzHeader]="'Uploading: ' + activeCount + ' file(s)'"> + <div class="upload-progress-wrapper"> + <div *ngFor="let task of uploadTasks; trackBy: trackByTask"> + <div class="progress-header"> + <span><b>{{ task.status }}</b>: {{ task.filePath }}</span> + <button + nz-button + nzType="text" + nzShape="circle" + [nz-tooltip]=" + (task.status === 'aborted' || task.status === 'finished') + ? 'Close' + : 'Cancel the upload' + " + (click)="onClickAbortUploadProgress(task)"> + <i + nz-icon + nzType="close" + nzTheme="outline"></i> + </button> + </div> + + <div + class="upload-stats" + *ngIf="task.status !== 'initializing'"> + <nz-progress + [nzPercent]="task.percentage" + [nzStatus]="getUploadStatus(task.status)"></nz-progress> + <nz-tag + *ngIf="task.status === 'uploading'" + [nzColor]="'blue'"> + <span class="fixed-width-speed">{{ formatSpeed(task.uploadSpeed) }}</span> - + <span class="fixed-width-time">{{ formatTime(task.totalTime ?? 0) }}</span> elapsed, + <span class="fixed-width-time" + >{{ formatTime(task.estimatedTimeRemaining ?? 0) }} left</span + > + </nz-tag> - <nz-tag *ngIf="(task.status === 'finished' || task.status === 'aborted')"> - Upload time: {{ formatTime(task.totalTime ?? 0) }} - </nz-tag> + <nz-tag *ngIf="(task.status === 'finished' || task.status === 'aborted')"> + Upload time: {{ formatTime(task.totalTime ?? 0) }} + </nz-tag> + </div> </div> </div> - </div> - </nz-collapse-panel> + </nz-collapse-panel> - <nz-divider - class="section-divider" - *ngIf="activeCount > 0"></nz-divider> + <nz-divider + class="section-divider" + *ngIf="activeCount > 0"></nz-divider> - <nz-collapse-panel - *ngIf="hasAnyActivity" - [nzHeader]="'Finished: ' + pendingChangesCount + ' file(s)'" - (nzActiveChange)="$event && stagedObjectsList.remeasureViewport()"> - <texera-dataset-staged-objects-list - #stagedObjectsList - [uploadTimeMap]="uploadTimeMap" - [did]="did" - [userMakeChangesEvent]="userMakeChanges" - (stagedObjectsChanged)="onStagedObjectsUpdated($event)"> - </texera-dataset-staged-objects-list> - </nz-collapse-panel> - </nz-collapse> + <nz-collapse-panel + *ngIf="hasAnyActivity" + [nzHeader]="'Finished: ' + pendingChangesCount + ' file(s)'" + (nzActiveChange)="$event && stagedObjectsList.remeasureViewport()"> + <texera-dataset-staged-objects-list + #stagedObjectsList + [uploadTimeMap]="uploadTimeMap" + [did]="did" + [userMakeChangesEvent]="userMakeChanges" + (stagedObjectsChanged)="onStagedObjectsUpdated($event)"> + </texera-dataset-staged-objects-list> + </nz-collapse-panel> + </nz-collapse> - <texera-dataset-staged-objects-list - *ngIf="!hasAnyActivity" - [uploadTimeMap]="uploadTimeMap" - [did]="did" - [userMakeChangesEvent]="userMakeChanges" - (stagedObjectsChanged)="onStagedObjectsUpdated($event)"> - </texera-dataset-staged-objects-list> + <texera-dataset-staged-objects-list + *ngIf="!hasAnyActivity" + [uploadTimeMap]="uploadTimeMap" + [did]="did" + [userMakeChangesEvent]="userMakeChanges" + (stagedObjectsChanged)="onStagedObjectsUpdated($event)"> + </texera-dataset-staged-objects-list> - <div - *ngIf="userHasWriteAccess() && userHasPendingChanges" - class="version-creator"> - <div class="version-input-container"> - <label>Version:</label> - <input - nz-input - [(ngModel)]="versionName" - placeholder="Describe the new version (Optional)" - [disabled]="isCreatingVersion" - (keydown.enter)="onClickOpenVersionCreator()" - class="version-input" /> - </div> - <div> - <button - nz-button - nzType="primary" - [nzLoading]="isCreatingVersion" - (click)="onClickOpenVersionCreator()" - class="create-dataset-version-button"> - Submit - </button> + <div + *ngIf="userHasWriteAccess() && userHasPendingChanges" + class="version-creator"> + <div class="version-input-container"> + <label>Version:</label> + <input + nz-input + [(ngModel)]="versionName" + placeholder="Describe the new version (Optional)" + [disabled]="isCreatingVersion" + (keydown.enter)="onClickOpenVersionCreator()" + class="version-input" /> + </div> + <div> + <button + nz-button + nzType="primary" + [nzLoading]="isCreatingVersion" + (click)="onClickOpenVersionCreator()" + class="create-dataset-version-button"> + Submit + </button> + </div> </div> - </div> - </nz-collapse-panel> - </nz-collapse> - </div> - </nz-sider> - </nz-layout> + </nz-collapse-panel> + </nz-collapse> + </div> + </nz-sider> + </nz-layout> + </div> </nz-tab> <nz-tab nzTitle="Settings" @@ -481,7 +521,7 @@ <div class="settings-field"> <label class="settings-field-label">Description</label> - <div class="settings-field-hint">Shown on cards and the Metadata tab.</div> + <div class="settings-field-hint">Shown on cards.</div> <texera-markdown-description [description]="datasetDescription" [editable]="userDatasetAccessLevel === 'WRITE'" 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 b5bbf7dfce..c0b17cfee1 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 @@ -356,6 +356,18 @@ nz-tabs { 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; +} + // The Settings tab shares the tab area's bounded height with the Data Card // tab. Give its content its own scroll region (mirroring the height:100% + // overflow-y:auto pattern used by other dashboard pages) so both cards can @@ -367,6 +379,104 @@ nz-tabs { 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; +} + .settings-general-card { max-width: 700px; margin: 24px auto 0; 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 3b40c12d09..d7faeaa920 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 @@ -90,6 +90,18 @@ describe("DatasetDetailComponent upload queue", () => { }) ), 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<HTMLElement> = 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<HTMLElement> = 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 3581459670..142d6a5e63 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); }
