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-6493-e08d3e6b423b960aa95a41d97b6275b7818b9476 in repository https://gitbox.apache.org/repos/asf/texera.git
commit c2d4a262a2ded9ca099c3d2fa81e01b2de16757c Author: Ranjani Veena Belavadi <[email protected]> AuthorDate: Mon Jul 27 14:59:03 2026 -0700 refactor(frontend): extract dataset settings into Settings tab (#6493) ### What changes were proposed in this PR? Extracted the dataset settings/admin functionality on the dataset detail page into a dedicated Settings tab, alongside existing content (now under a Data Card tab). Part of the parent tab-based layout refactor (#6394). Visibility toggle, downloadable toggle, and description editor moved from the header's gear-icon popover into the new Settings tab. The gear icon and popover are removed. Added dataset rename (input + Save), calling the existing `DatasetService.updateDatasetName()` and reusing the name-sanitization logic already used in the dataset version creator. Added dataset delete, calling the existing `DatasetService.deleteDatasets()` with the same `nz-popconfirm` confirmation pattern used on the Datasets list page; redirects to the list on success. Before: settings were scattered, visibility/downloadable behind a gear icon, description editable only in the header, rename/delete only reachable from the Datasets list page. <img width="1915" height="1015" alt="settings_tab_recording_old" src="https://github.com/user-attachments/assets/3d29d0f3-0fbb-4caa-8c8f-ee0348df0ab1" /> After: all dataset settings are consolidated in one Settings tab on the detail page itself. <img width="1915" height="1018" alt="settings_tab_recording" src="https://github.com/user-attachments/assets/348effd0-e8fd-4583-baff-118af9c83fee" /> ### Any related issues, documentation, discussions? Closes #6395 ### How was this PR tested? Added 7 unit tests to `dataset-detail.component.spec.ts` covering the two new methods (`onSaveDatasetName`, `onDeleteDataset`): name sanitization, success/error paths, and navigation on delete. The relocated toggles/description editor already had existing test coverage prior to this PR, so no new tests were needed there. 66/66 tests passing (59 pre-existing, 7 new). Also manually tested on UI, since the relocated functionality itself is unchanged behavior: - Renamed a dataset; confirmed persistence and correct sanitization of invalid characters. - Edited and saved the description; confirmed it reflects on the Datasets list page. - Toggled visibility and downloadable; confirmed state persists and header badges update accordingly. - Deleted a test dataset; confirmed confirmation prompt, successful deletion, and redirect to the Datasets list with the dataset removed. - Confirmed the Data Card tab (file browser, version history, uploads) is unaffected. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Claude Opus 4.8) --------- Signed-off-by: Ranjani Veena Belavadi <[email protected]> Co-authored-by: Xuan Gu <[email protected]> --- .../dataset-detail.component.html | 770 +++++++++++---------- .../dataset-detail.component.scss | 77 +++ .../dataset-detail.component.spec.ts | 145 +++- .../dataset-detail.component.ts | 66 +- 4 files changed, 703 insertions(+), 355 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 3c2a031617..f50dcbd0a6 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 @@ -74,47 +74,6 @@ nzTheme="outline"></i> {{ formatCount(likeCount) }} </nz-tag> - - <button - *ngIf="userHasWriteAccess()" - nz-button - nzType="text" - class="tag-settings-btn" - nz-popover - nzPopoverTrigger="click" - nzPopoverPlacement="bottomRight" - [nzPopoverContent]="tagSettingsPopover" - nz-tooltip - nzTooltipTitle="Settings"> - <i - nz-icon - nzType="setting" - nzTheme="outline"></i> - </button> - - <ng-template #tagSettingsPopover> - <div> - <div class="tag-settings-title">Quick Settings</div> - <div class="tag-settings-row"> - <span>Visibility:</span> - <nz-switch - [ngModel]="datasetIsPublic" - (ngModelChange)="onPublicStatusChange($event)" - [nzDisabled]="userDatasetAccessLevel !== 'WRITE'" - nzCheckedChildren="public" - nzUnCheckedChildren="private"></nz-switch> - </div> - <div class="tag-settings-row"> - <span>Downloadable:</span> - <nz-switch - [ngModel]="datasetIsDownloadable" - (ngModelChange)="onDownloadableStatusChange($event)" - [nzDisabled]="!isOwner" - nzCheckedChildren="allowed" - nzUnCheckedChildren="blocked"></nz-switch> - </div> - </div> - </ng-template> </div> </div> <img @@ -127,366 +86,477 @@ <div class="description-section"> <texera-markdown-description [description]="datasetDescription" - [editable]="userDatasetAccessLevel === 'WRITE'" - [enableViewMore]="true" - (descriptionChange)="onDatasetDescriptionChange($event)"> + [editable]="false" + [enableViewMore]="true"> </texera-markdown-description> </div> </nz-card> </div> -<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> +<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> + + <span + *ngIf="currentFileSize" + class="file-size"> + <i + nz-icon + nzType="file" + nzTheme="outline" + class="icon-file"></i> + {{ formatSize(currentFileSize) }} + </span> + </h3> + </div> + <div style="display: flex"> <button nz-button - nzType="text" - nzSize="small" - class="copy-path-btn" - nz-tooltip - nzTooltipTitle="Copy file path" - *ngIf="currentDisplayedFileName" - (click)="copyCurrentFilePath()"> + *ngIf="selectedVersion" + nz-tooltip="Download the file" + [disabled]="!isLogin || !isDownloadAllowed()" + (click)="onClickDownloadCurrentFile()"> <i nz-icon - nzType="copy" - nzTheme="twotone"> + nzTheme="outline" + nzType="download"> </i> </button> - </span> - - <span - *ngIf="currentFileSize" - class="file-size"> - <i - nz-icon - nzType="file" - nzTheme="outline" - class="icon-file"></i> - {{ formatSize(currentFileSize) }} - </span> - </h3> - </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> - </div> - </nz-card> - <nz-empty - class="empty-version-indicator" - *ngIf="!selectedVersion" - nzNotFoundContent="No version is selected"></nz-empty> - - <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"> + *ngIf="!isMaximized && selectedVersion" + nz-tooltip="Maximize View" + (click)="onClickScaleTheView()"> <i nz-icon - nzType="download" - nzTheme="outline"></i> + nzTheme="outline" + nzType="expand"> + </i> </button> - </div> - <ng-container *ngIf="selectedVersion"> - <div class="version-size"> + <button + nz-button + *ngIf="isMaximized && selectedVersion" + nz-tooltip="Minimize View" + (click)="onClickScaleTheView()"> <i nz-icon - nzType="database" nzTheme="outline" - class="icon-database"></i> - Version Size: {{ formatSize(currentDatasetVersionSize) }} - </div> - <div - *ngIf="selectedVersionCreationTime" - class="version-date"> + nzType="compress"> + </i> + </button> + <button + *ngIf="!isRightBarCollapsed" + nz-button + nz-tooltip="Hide the right bar" + (click)="onClickHideRightBar()"> <i nz-icon - nzType="calendar" nzTheme="outline" - class="icon-database"></i> - Created at: {{ selectedVersionCreationTime }} - </div> - </ng-container> + 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> - <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> + </nz-card> + <nz-empty + class="empty-version-indicator" + *ngIf="!selectedVersion" + nzNotFoundContent="No version is selected"></nz-empty> - <nz-collapse - nzGhost - class="upload-status-panels"> + <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 - *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 - > + 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 - nzType="text" - nzShape="circle" - nz-tooltip - [nzTooltipTitle]="'Remove from queue'" - (click)="cancelExistingUpload(fileName)"> - <span + nz-tooltip="Download Dataset" + (click)="onClickDownloadVersionAsZip()" + *ngIf="selectedVersion" + [disabled]="!isLogin || !isDownloadAllowed()" + class="spaced-button"> + <i nz-icon - nzType="close" - nzTheme="outline"></span> + nzType="download" + nzTheme="outline"></i> </button> </div> - </cdk-virtual-scroll-viewport> + <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"> + <i + nz-icon + nzType="calendar" + nzTheme="outline" + class="icon-database"></i> + Created at: {{ selectedVersionCreationTime }} + </div> + </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 - *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]=" + 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 + 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 + [nzTooltipTitle]="'Remove from queue'" + (click)="cancelExistingUpload(fileName)"> + <span + nz-icon + nzType="close" + nzTheme="outline"></span> + </button> + </div> + </cdk-virtual-scroll-viewport> + </nz-collapse-panel> + + <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> + (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> + <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> - </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 + *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> + </div> </nz-collapse-panel> </nz-collapse> + </div> + </nz-sider> + </nz-layout> + </nz-tab> + <nz-tab + nzTitle="Settings" + *ngIf="userHasWriteAccess()"> + <div class="settings-tab-content"> + <nz-card class="settings-general-card"> + <h3 class="settings-card-title">General</h3> - <texera-dataset-staged-objects-list - *ngIf="!hasAnyActivity" - [uploadTimeMap]="uploadTimeMap" - [did]="did" - [userMakeChangesEvent]="userMakeChanges" - (stagedObjectsChanged)="onStagedObjectsUpdated($event)"> - </texera-dataset-staged-objects-list> + <div class="settings-name-row"> + <div class="settings-name-label"> + <label>Dataset name</label> + <p class="settings-hint">Lowercase letters, numbers and hyphens.</p> + </div> + <div class="settings-name-controls"> + <input + nz-input + [(ngModel)]="editedDatasetName" /> + <button + nz-button + nzType="primary" + (click)="onSaveDatasetName()"> + Save + </button> + </div> + </div> - <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> + <nz-divider></nz-divider> + + <div class="settings-field"> + <label class="settings-field-label">Description</label> + <div class="settings-field-hint">Shown on cards and the Metadata tab.</div> + <texera-markdown-description + [description]="datasetDescription" + [editable]="userDatasetAccessLevel === 'WRITE'" + [enableViewMore]="true" + (descriptionChange)="onDatasetDescriptionChange($event)"> + </texera-markdown-description> + </div> + </nz-card> + + <nz-card class="settings-general-card"> + <h3 class="settings-card-title">Access & visibility</h3> + + <div class="settings-name-row"> + <div class="settings-name-label"> + <label>Visibility</label> + <p class="settings-hint"> + {{ datasetIsPublic ? "Public — anyone can view this dataset." : "Private — only you and invited + collaborators can see this dataset." }} + </p> + </div> + <div class="settings-name-controls"> + <nz-switch + [ngModel]="datasetIsPublic" + (ngModelChange)="onPublicStatusChange($event)" + [nzDisabled]="userDatasetAccessLevel !== 'WRITE'" + nzCheckedChildren="public" + nzUnCheckedChildren="private"></nz-switch> + </div> + </div> + + <nz-divider></nz-divider> + + <div class="settings-name-row"> + <div class="settings-name-label"> + <label>Downloadable</label> + <p class="settings-hint"> + {{ datasetIsDownloadable ? "Viewers can download this dataset." : "Viewers can browse files but cannot + download them." }} + </p> + </div> + <div class="settings-name-controls"> + <nz-switch + [ngModel]="datasetIsDownloadable" + (ngModelChange)="onDownloadableStatusChange($event)" + [nzDisabled]="!isOwner" + nzCheckedChildren="allowed" + nzUnCheckedChildren="blocked"></nz-switch> </div> - </nz-collapse-panel> - </nz-collapse> + </div> + </nz-card> + + <nz-card class="settings-general-card"> + <h3 class="settings-card-title">Delete</h3> + + <div class="settings-name-row"> + <div class="settings-name-label"> + <label>Delete dataset</label> + <p class="settings-hint">This permanently deletes the dataset and all of its versions.</p> + </div> + <div class="settings-name-controls"> + <button + nz-button + nzType="text" + title="Delete" + [disabled]="!isOwner" + (nzOnConfirm)="onDeleteDataset()" + nz-popconfirm + nzPopconfirmTitle="Confirm to delete this dataset."> + <i + nz-icon + nzType="delete"></i> + </button> + </div> + </div> + </nz-card> </div> - </nz-sider> -</nz-layout> + </nz-tab> +</nz-tabs> 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 d5ef708f88..b5bbf7dfce 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,80 @@ nz-select { border-radius: 8px; margin-right: 80px; } + +nz-tabs { + margin-top: 6px; +} + +::ng-deep .ant-tabs-nav { + padding-left: 16px; +} + +// 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 +// be scrolled fully into view, with bottom padding for breathing room below +// the last card. +.settings-tab-content { + height: 100%; + overflow-y: auto; + padding-bottom: 24px; +} + +.settings-general-card { + max-width: 700px; + margin: 24px auto 0; + border-radius: 16px; + border: 1px solid #d9d9d9; + + .settings-card-title { + font-weight: 600; + margin-bottom: 20px; + } + + .settings-field-label { + display: block; + font-weight: 600; + } + + .settings-field-hint { + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + margin-bottom: 8px; + } + + .settings-name-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + + .settings-name-label label { + font-weight: 600; + } + + .settings-hint { + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + margin: 4px 0 0; + } + + .settings-name-controls { + display: flex; + gap: 8px; + flex-shrink: 0; + + input { + border-radius: 8px; + } + + button { + border-radius: 8px; + } + } + } +} + +::ng-deep .settings-general-card button { + border-radius: 8px; +} 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 91d8acce94..3b40c12d09 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 @@ -18,7 +18,7 @@ */ import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { of, Subject, throwError } from "rxjs"; import { NzModalService } from "ng-zorro-antd/modal"; import { MarkdownService } from "ngx-markdown"; @@ -38,6 +38,7 @@ import { Dataset, DatasetVersion } from "../../../../../common/type/dataset"; import { DashboardDataset } from "../../../../type/dashboard-dataset.interface"; import { HttpErrorResponse } from "@angular/common/http"; import { format } from "date-fns"; +import { USER_DATASET } from "../../../../../app-routing.constant"; describe("DatasetDetailComponent upload queue", () => { let fixture: ComponentFixture<DatasetDetailComponent>; @@ -429,6 +430,8 @@ describe("DatasetDetailComponent behavior", () => { updateDatasetDownloadable: vi.fn(() => of({})), updateDatasetCoverImage: vi.fn(() => of({})), updateDatasetDescription: vi.fn(() => of({})), + updateDatasetName: vi.fn(() => of({})), + deleteDatasets: vi.fn(() => of({})), deleteDatasetFile: vi.fn(() => of({})), getDatasetDiff: vi.fn(() => of([])), multipartUpload: vi.fn(() => of()), @@ -1036,4 +1039,144 @@ describe("DatasetDetailComponent behavior", () => { expect(component.trackByTask(0, task)).toBe("owner/data/file.csv"); }); }); + + describe("onSaveDatasetName", () => { + it("seeds editedDatasetName from the loaded dataset name", () => { + datasetServiceStub.getDataset.mockReturnValue( + of(makeDashboardDataset({ dataset: makeDataset({ name: "seed-name" }) })) + ); + createComponent(); + component.did = 5; + component.retrieveDatasetInfo(); + + expect(component.editedDatasetName).toBe("seed-name"); + }); + + it("persists a valid name unchanged and toasts success", () => { + datasetServiceStub.updateDatasetName.mockReturnValue(of({})); + createComponent(); + component.did = 5; + // Mixed case, hyphen and underscore are all valid: the name must be saved + // verbatim, not rewritten. + component.editedDatasetName = "My-Cool_Dataset"; + + component.onSaveDatasetName(); + + expect(datasetServiceStub.updateDatasetName).toHaveBeenCalledWith(5, "My-Cool_Dataset"); + expect(component.datasetName).toBe("My-Cool_Dataset"); + expect(component.editedDatasetName).toBe("My-Cool_Dataset"); + expect(notificationServiceStub.success).toHaveBeenCalledWith("Dataset name updated to 'My-Cool_Dataset'"); + }); + + it("rejects an invalid name with a validation error and does not call the rename API", () => { + createComponent(); + component.did = 5; + component.datasetName = "original"; + component.editedDatasetName = "My Cool Dataset"; // spaces are not allowed + + component.onSaveDatasetName(); + + expect(datasetServiceStub.updateDatasetName).not.toHaveBeenCalled(); + expect(component.datasetName).toBe("original"); + expect(notificationServiceStub.error).toHaveBeenCalledWith( + "Invalid dataset name: only letters, numbers, underscores, and hyphens are allowed (max 128 characters)" + ); + }); + + it("toasts an error and leaves the name unchanged when the rename fails", () => { + datasetServiceStub.updateDatasetName.mockReturnValue(throwError(() => new Error("boom"))); + createComponent(); + component.did = 5; + component.datasetName = "original"; + component.editedDatasetName = "new-name"; + + component.onSaveDatasetName(); + + expect(component.datasetName).toBe("original"); + expect(notificationServiceStub.error).toHaveBeenCalledWith("boom"); + }); + + it("does nothing when there is no did", () => { + createComponent(); + component.did = undefined; + component.editedDatasetName = "whatever"; + + component.onSaveDatasetName(); + + expect(datasetServiceStub.updateDatasetName).not.toHaveBeenCalled(); + }); + }); + + describe("onDeleteDataset", () => { + it("deletes the dataset, toasts success and navigates back to the dataset list", () => { + datasetServiceStub.deleteDatasets.mockReturnValue(of({})); + createComponent(); + const navigateSpy = vi.spyOn(TestBed.inject(Router), "navigate").mockResolvedValue(true); + component.did = 5; + component.datasetName = "DS"; + + component.onDeleteDataset(); + + expect(datasetServiceStub.deleteDatasets).toHaveBeenCalledWith(5); + expect(notificationServiceStub.success).toHaveBeenCalledWith("Dataset DS was deleted"); + expect(navigateSpy).toHaveBeenCalledWith([USER_DATASET]); + }); + + it("toasts an error and does not navigate when the deletion fails", () => { + datasetServiceStub.deleteDatasets.mockReturnValue(throwError(() => new Error("boom"))); + createComponent(); + const navigateSpy = vi.spyOn(TestBed.inject(Router), "navigate").mockResolvedValue(true); + component.did = 5; + + component.onDeleteDataset(); + + expect(notificationServiceStub.error).toHaveBeenCalledWith("boom"); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + + it("does nothing when there is no did", () => { + createComponent(); + const navigateSpy = vi.spyOn(TestBed.inject(Router), "navigate").mockResolvedValue(true); + component.did = undefined; + + component.onDeleteDataset(); + + expect(datasetServiceStub.deleteDatasets).not.toHaveBeenCalled(); + expect(navigateSpy).not.toHaveBeenCalled(); + }); + }); + + describe("delete button disabled state", () => { + // The Settings tab (and its Delete card) only render for WRITE access; the + // delete button itself is owner-only, mirroring the Downloadable switch's + // [nzDisabled]="!isOwner". Renders WRITE access with the given ownership, + // activates the (inactive) Settings tab so its pane is in the DOM, then + // returns the delete button element. + const renderDeleteButton = (isOwner: boolean): HTMLButtonElement => { + datasetServiceStub.getDataset.mockReturnValue(of(makeDashboardDataset({ accessPrivilege: "WRITE", isOwner }))); + createComponent(); + fixture.detectChanges(); + + const tabButtons: NodeListOf<HTMLElement> = fixture.nativeElement.querySelectorAll(".ant-tabs-tab-btn"); + const settingsTab = Array.from(tabButtons).find(tab => tab.textContent?.includes("Settings")); + settingsTab?.click(); + fixture.detectChanges(); + + return fixture.nativeElement.querySelector('button[title="Delete"]') as HTMLButtonElement; + }; + + it("disables the delete button for a non-owner with write access", () => { + const button = renderDeleteButton(false); + + expect(button).toBeTruthy(); + expect(button.disabled).toBe(true); + }); + + it("enables the delete button for the owner", () => { + const button = renderDeleteButton(true); + + expect(button).toBeTruthy(); + expect(button.disabled).toBe(false); + }); + }); }); 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 bdef5e0168..3581459670 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 @@ -18,9 +18,15 @@ */ import { Component, EventEmitter, OnInit, Output, ViewChild } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; +import { USER_DATASET } from "../../../../../app-routing.constant"; +import { extractErrorMessage } from "../../../../../common/util/error"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { DatasetService, MultipartUploadProgress } from "../../../../service/user/dataset/dataset.service"; +import { + DatasetService, + MultipartUploadProgress, + validateDatasetName, +} from "../../../../service/user/dataset/dataset.service"; import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from "ng-zorro-antd/resizable"; import { DatasetFileNode, @@ -51,7 +57,7 @@ import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patc import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; import { NzButtonComponent } from "ng-zorro-antd/button"; -import { NzPopoverDirective } from "ng-zorro-antd/popover"; +import { NzPopconfirmDirective } from "ng-zorro-antd/popconfirm"; import { NzSwitchComponent } from "ng-zorro-antd/switch"; import { FormsModule } from "@angular/forms"; import { MarkdownDescriptionComponent } from "../../markdown-description/markdown-description.component"; @@ -68,6 +74,7 @@ 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; @@ -88,7 +95,7 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100; NzIconDirective, NzSpaceCompactItemDirective, NzButtonComponent, - NzPopoverDirective, + NzPopconfirmDirective, NzSwitchComponent, FormsModule, MarkdownDescriptionComponent, @@ -114,11 +121,14 @@ export const ABORT_RETRY_BACKOFF_BASE_MS = 100; CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf, + NzTabsComponent, + NzTabComponent, ], }) export class DatasetDetailComponent implements OnInit { public did: number | undefined; public datasetName: string = ""; + public editedDatasetName: string = ""; public datasetDescription: string = ""; public datasetCreationTime: string = ""; public datasetCreationTimeTooltip: string = ""; @@ -194,6 +204,7 @@ export class DatasetDetailComponent implements OnInit { constructor( private route: ActivatedRoute, + private router: Router, private modalService: NzModalService, private datasetService: DatasetService, private notificationService: NotificationService, @@ -359,6 +370,7 @@ export class DatasetDetailComponent implements OnInit { .subscribe(dashboardDataset => { const dataset = dashboardDataset.dataset; this.datasetName = dataset.name; + this.editedDatasetName = dataset.name; this.datasetDescription = dataset.description; this.userDatasetAccessLevel = dashboardDataset.accessPrivilege; this.datasetIsPublic = dataset.isPublic; @@ -917,6 +929,52 @@ export class DatasetDetailComponent implements OnInit { }); } + onSaveDatasetName(): void { + if (!this.did) { + return; + } + // Reject invalid names outright instead of silently rewriting them, matching + // the shared validation used by the other rename entry points (PR #6426). + const name = this.editedDatasetName; + const nameError = validateDatasetName(name); + if (nameError) { + this.notificationService.error(nameError); + return; + } + + this.datasetService + .updateDatasetName(this.did, name) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.datasetName = name; + this.editedDatasetName = name; + this.notificationService.success(`Dataset name updated to '${name}'`); + }, + error: (err: unknown) => { + this.notificationService.error(extractErrorMessage(err)); + }, + }); + } + + onDeleteDataset(): void { + if (!this.did) { + return; + } + this.datasetService + .deleteDatasets(this.did) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + this.notificationService.success(`Dataset ${this.datasetName} was deleted`); + this.router.navigate([USER_DATASET]); + }, + error: (err: unknown) => { + this.notificationService.error(extractErrorMessage(err)); + }, + }); + } + async copyCurrentFilePath(): Promise<void> { if (!this.currentDisplayedFileName) { return;
