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-8942-3fa608d04c124ec6c646e194f26f2cd391f9c6a9 in repository https://gitbox.apache.org/repos/asf/texera.git
commit 8bfe034af17f88e8396b4961714658a48f79cff2 Author: Matthew B. <[email protected]> AuthorDate: Sat Oct 10 08:50:21 2026 +0000 refactor(frontend): extract shared VersionsFilesBrowser component (#8942) ### What changes were proposed in this PR? - Extracted the duplicated "Versions & Files" tab from `dataset-detail` and `model-detail` into one shared presentational component, `texera-versions-files-browser` (template, SCSS, toolbar view-state, sider resize, and copy-path logic). - Both detail pages now render `<texera-versions-files-browser>` with inputs/outputs and keep their own data loading and version-selection logic. - Kept `isMaximized` and `isRightBarCollapsed` parent-owned as two-way bindings, since `isMaximized` also gates the page header outside the tab; the child reports toggles rather than owning the flags. - Moved the view-state unit tests (sider resize, animation-frame scheduling, copy-path, toggle emits) into the new component's spec; parent specs keep their integration coverage by rendering the real child. ### Any related issues, documentation, discussions? Closes: #8941 ### How was this PR tested? - Run `cd frontend && npx ng test --include='**/versions-files-browser.component.spec.ts' --include='**/dataset-detail.component.spec.ts' --include='**/model-detail.component.spec.ts'`, expect all 231 tests to pass. - Open a dataset detail page, switch to the "Versions & Files" tab, confirm the version picker, file tree, file renderer, resize handle, maximize/minimize, hide/show right bar, copy-path, and zip download all behave as before. - Repeat on a model detail page and confirm identical behavior, and confirm the model Settings tab (which still uses nz-select) is unaffected. ### Was this PR authored or co-authored using generative AI tooling? Co-authored with Claude Opus 4.8 in compliance with ASF π€ Generated with [Claude Code](https://claude.com/claude-code) --- .../dataset-detail.component.html | 242 +++------------------ .../dataset-detail.component.spec.ts | 126 ----------- .../dataset-detail.component.ts | 64 +----- .../model-detail.component.html | 242 +++------------------ .../model-detail.component.spec.ts | 63 +----- .../user-model-explorer/model-detail.component.ts | 61 +----- .../versions-files-browser.component.html | 231 ++++++++++++++++++++ .../versions-files-browser.component.scss | 136 ++++++++++++ .../versions-files-browser.component.spec.ts | 182 ++++++++++++++++ .../versions-files-browser.component.ts | 177 +++++++++++++++ 10 files changed, 806 insertions(+), 718 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 acaf1ec5e8..7ef75951fa 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 @@ -251,218 +251,36 @@ </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> - - <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 - *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" - [resourceId]="did" - [versionId]="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 - 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"> - <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]="userHasWriteAccess()" - [isCoverSettable]="userHasWriteAccess()" - (selectedTreeNode)="onVersionFileTreeNodeSelected($event)" - (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)" - (setCoverImage)="onSetCoverImage($event)"> - </texera-user-dataset-version-filetree> - </nz-collapse-panel> - </nz-collapse> - - <nz-divider *ngIf="userDatasetAccessLevel === 'WRITE'"></nz-divider> - <texera-version-uploader - *ngIf="userDatasetAccessLevel === 'WRITE'" - [resourceId]="did" - [ownerEmail]="ownerEmail" - [resourceName]="datasetName" - [endpoint]="datasetEndpoint" - [createVersion]="createDatasetVersion" - (versionCreated)="onVersionCreated()" - (uploadsInFlightChange)="uploadsInFlight = $event"> - </texera-version-uploader> - </div> - </nz-sider> - </nz-layout> - </div> + <texera-versions-files-browser + [(isMaximized)]="isMaximized" + [(isRightBarCollapsed)]="isRightBarCollapsed" + [currentDisplayedFileName]="currentDisplayedFileName" + [currentFileSize]="currentFileSize" + [currentVersionSize]="currentDatasetVersionSize" + [selectedVersionCreationTime]="selectedVersionCreationTime" + [versions]="versions" + [selectedVersion]="selectedVersion" + (versionSelected)="onVersionSelected($event)" + [fileTreeNodeList]="fileTreeNodeList" + (fileTreeNodeSelected)="onVersionFileTreeNodeSelected($event)" + (fileDeleted)="onPreviouslyUploadedFileDeleted($event)" + (setCoverImage)="onSetCoverImage($event)" + [resourceId]="did" + [versionId]="selectedVersion?.dvid" + [ownerEmail]="ownerEmail" + [resourceName]="datasetName" + [endpoint]="datasetEndpoint" + [createVersion]="createDatasetVersion" + (versionCreated)="onVersionCreated()" + (uploadsInFlightChange)="uploadsInFlight = $event" + [isLogin]="isLogin" + [writeAccess]="userHasWriteAccess()" + [downloadAllowed]="isDownloadAllowed()" + emptyMessage="No version is selected" + downloadZipTooltip="Download Dataset" + (downloadCurrentFile)="onClickDownloadCurrentFile()" + (downloadVersionAsZip)="onClickDownloadVersionAsZip()"> + </texera-versions-files-browser> </nz-tab> <nz-tab nzTitle="Settings" 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 0aa5c93707..5074cfad32 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 @@ -24,7 +24,6 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { ActivatedRoute, Router } from "@angular/router"; import { concat, of, Subject, throwError } from "rxjs"; import { NzModalService } from "ng-zorro-antd/modal"; -import { NzResizableDirective } from "ng-zorro-antd/resizable"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { MarkdownService } from "ngx-markdown"; import { DatasetDetailComponent } from "./dataset-detail.component"; @@ -43,7 +42,6 @@ import { commonTestImports, commonTestProviders } from "../../../../../common/te import { Contributor, Dataset, DatasetVersion } from "../../../../../common/type/dataset"; import { DashboardDataset } from "../../../../type/dashboard-dataset.interface"; import { HttpErrorResponse, HttpStatusCode } from "@angular/common/http"; -import { NzResizeEvent } from "ng-zorro-antd/resizable"; import { format } from "date-fns"; import { USER_DATASET } from "../../../../../app-routing.constant"; @@ -232,15 +230,6 @@ describe("DatasetDetailComponent rendered explorer", () => { expect((fixture.nativeElement as HTMLElement).querySelector(".copy-path-btn")).not.toBeNull(); }); - it("copies the path of the file being shown", () => { - const spy = vi.spyOn(component, "copyCurrentFilePath").mockResolvedValue(undefined); - const el = render(c => (c.currentDisplayedFileName = "a/b.csv")); - - el.querySelector<HTMLElement>(".copy-path-btn")!.click(); - - expect(spy).toHaveBeenCalledTimes(1); - }); - it("shows the file size in human units, and nothing when it is unknown", () => { const el = render(c => { c.currentDisplayedFileName = "a/b.csv"; @@ -1107,20 +1096,6 @@ describe("DatasetDetailComponent behavior", () => { }); }); - describe("view flags", () => { - beforeEach(() => createComponent()); - - it("toggles the maximize and right-bar flags", () => { - expect(component.isMaximized).toBe(false); - component.onClickScaleTheView(); - expect(component.isMaximized).toBe(true); - - expect(component.isRightBarCollapsed).toBe(false); - component.onClickHideRightBar(); - expect(component.isRightBarCollapsed).toBe(true); - }); - }); - describe("toggleLike", () => { it("unlikes and decrements the like count when currently liked", () => { hubServiceStub.postUnlike.mockReturnValue(of(true)); @@ -1371,54 +1346,6 @@ describe("DatasetDetailComponent behavior", () => { }); }); - describe("copyCurrentFilePath", () => { - let originalClipboardDescriptor: PropertyDescriptor | undefined; - let writeText: ReturnType<typeof vi.fn>; - - beforeEach(() => { - // Capture the original own-property descriptor (undefined if navigator has no own - // `clipboard`, e.g. under jsdom) so afterEach can restore the exact shape. - originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); - writeText = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - createComponent(); - }); - - afterEach(() => { - if (originalClipboardDescriptor) { - Object.defineProperty(navigator, "clipboard", originalClipboardDescriptor); - } else { - delete (navigator as any).clipboard; - } - }); - - it("writes the displayed path to the clipboard and toasts success", async () => { - component.currentDisplayedFileName = "/a/b/c.txt"; - - await component.copyCurrentFilePath(); - - expect(writeText).toHaveBeenCalledWith("/a/b/c.txt"); - expect(notificationServiceStub.success).toHaveBeenCalledWith("File path copied to clipboard"); - }); - - it("does nothing when no file is displayed", async () => { - component.currentDisplayedFileName = ""; - - await component.copyCurrentFilePath(); - - expect(writeText).not.toHaveBeenCalled(); - }); - - it("toasts an error when the clipboard write rejects", async () => { - writeText.mockRejectedValue(new Error("denied")); - component.currentDisplayedFileName = "/a/b/c.txt"; - - await component.copyCurrentFilePath(); - - expect(notificationServiceStub.error).toHaveBeenCalledWith("Failed to copy file path"); - }); - }); - describe("version-node selection", () => { it("onVersionFileTreeNodeSelected loads the selected node's content", () => { const node = { name: "file.csv", type: "file" } as unknown as Parameters< @@ -1971,38 +1898,6 @@ describe("DatasetDetailComponent behavior", () => { expect(component.isMaximized).toBe(true); }); - - // βββ sider resize βββββββββββββββββββββββββββββββββββββββββββββββββββββββ - - it("applies the dragged sider width on the next animation frame", async () => { - renderWith({ did: 5 }); - - component.onSideResize({ width: 321 } as NzResizeEvent); - // the handler defers to requestAnimationFrame; let that frame run - await new Promise(resolve => requestAnimationFrame(() => resolve(null))); - - expect(component.siderWidth).toBe(321); - }); - - it("cancels the frame the previous resize scheduled", () => { - renderWith({ did: 5 }); - // Hand out a known frame id so the assertion below pins down *which* frame is - // cancelled: the component starts with id = -1, so merely asserting that - // cancelAnimationFrame was called would pass even if the id were never tracked. - const request = vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(100); - const cancel = vi.spyOn(globalThis, "cancelAnimationFrame"); - try { - component.onSideResize({ width: 100 } as NzResizeEvent); - cancel.mockClear(); // drop the initial cancel(-1) - - component.onSideResize({ width: 200 } as NzResizeEvent); - - expect(cancel).toHaveBeenCalledWith(100); - } finally { - cancel.mockRestore(); - request.mockRestore(); - } - }); }); }); @@ -2443,27 +2338,6 @@ describe("DatasetDetailComponent rendered template", () => { expect(el.querySelector(".dataset-header")).not.toBeNull(); expect(byTooltip("Minimize View")).toBeUndefined(); }); - - it("applies a width the resize handle reports, between the bounds it declares", async () => { - const sider = fixture.debugElement.query(By.css("nz-sider")); - expect(sider.nativeElement.style.width).toBe("400px"); - - // The drag itself belongs to NzResizableDirective; what this component owns - // is the bounds it hands the directive and what it does with the reported - // width. Both have to be pinned, and in the right order β swapped bounds - // would let the handle collapse the sider past its minimum. - const resizable = sider.injector.get(NzResizableDirective); - expect(resizable.nzMinWidth).toBe(component.MIN_SIDER_WIDTH); - expect(resizable.nzMaxWidth).toBe(component.MAX_SIDER_WIDTH); - expect(resizable.nzMinWidth).toBeLessThan(resizable.nzMaxWidth as number); - - sider.triggerEventHandler("nzResize", { width: 520 }); - // The new width is applied on the next animation frame. - await new Promise(resolve => requestAnimationFrame(() => resolve(null))); - fixture.detectChanges(); - - expect(sider.nativeElement.style.width).toBe("520px"); - }); }); describe("version picker", () => { 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 bb9521aa67..f9d8f70c16 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 @@ -23,7 +23,6 @@ import { USER_DATASET } from "../../../../../app-routing.constant"; import { extractErrorMessage } from "../../../../../common/util/error"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { DatasetService, validateDatasetName } from "../../../../service/user/dataset/dataset.service"; -import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from "ng-zorro-antd/resizable"; import { DatasetFileNode, getFullPathFromDatasetFileNode, @@ -58,18 +57,13 @@ 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"; -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"; -import { UserDatasetVersionFiletreeComponent } from "./user-dataset-version-filetree/user-dataset-version-filetree.component"; import { NzDividerComponent } from "ng-zorro-antd/divider"; -import { VersionUploaderComponent } from "../../version-uploader/version-uploader.component"; import { DATASET_FILE_RESOURCE_ENDPOINT } from "../../../../service/user/file-resource/file-resource-endpoint"; import { NzInputDirective } from "ng-zorro-antd/input"; +import { VersionsFilesBrowserComponent } from "../../versions-files-browser/versions-files-browser.component"; export const THROTTLE_TIME_MS = 1000; @@ -92,29 +86,19 @@ export const THROTTLE_TIME_MS = 1000; NzSwitchComponent, FormsModule, MarkdownDescriptionComponent, - NzLayoutComponent, - NzContentComponent, NzWaveDirective, - NzEmptyComponent, NzTabsComponent, NzTabComponent, - UserDatasetFileRendererComponent, - NzSiderComponent, - NzResizableDirective, - NzResizeHandleComponent, NzCollapseComponent, NzCollapsePanelComponent, - NzSelectComponent, - NgFor, - NzOptionComponent, - UserDatasetVersionFiletreeComponent, NzDividerComponent, - VersionUploaderComponent, + NgFor, NzInputDirective, NzDropdownDirective, NzDropdownMenuComponent, NzMenuDirective, NzMenuItemComponent, + VersionsFilesBrowserComponent, ], }) export class DatasetDetailComponent implements OnInit { @@ -136,8 +120,10 @@ export class DatasetDetailComponent implements OnInit { public currentFileSize: number | undefined; public currentDatasetVersionSize: number | undefined; - public isRightBarCollapsed = false; + // The versions/files browser is presentational; its two toolbar flags live here (maximizing + // the file view also hides the page header) and are shared through two-way bindings. public isMaximized = false; + public isRightBarCollapsed = false; public versions: ReadonlyArray<DatasetVersion> = []; public selectedVersion: DatasetVersion | undefined; @@ -162,7 +148,8 @@ export class DatasetDetailComponent implements OnInit { readonly datasetEndpoint = DATASET_FILE_RESOURCE_ENDPOINT; - @ViewChild(VersionUploaderComponent) private versionUploader?: VersionUploaderComponent; + @ViewChild(VersionsFilesBrowserComponent) + private versionsFilesBrowser?: VersionsFilesBrowserComponent<DatasetVersion>; constructor( private route: ActivatedRoute, @@ -183,18 +170,6 @@ export class DatasetDetailComponent implements OnInit { }); } - // item for control the resizeable sider - MAX_SIDER_WIDTH = 600; - MIN_SIDER_WIDTH = 150; - siderWidth = 400; - id = -1; - onSideResize({ width }: NzResizeEvent): void { - cancelAnimationFrame(this.id); - this.id = requestAnimationFrame(() => { - this.siderWidth = width!; - }); - } - ngOnInit(): void { this.route.params .pipe( @@ -415,14 +390,6 @@ export class DatasetDetailComponent implements OnInit { .subscribe(); }; - onClickScaleTheView() { - this.isMaximized = !this.isMaximized; - } - - onClickHideRightBar() { - this.isRightBarCollapsed = !this.isRightBarCollapsed; - } - onVersionSelected(version: DatasetVersion | undefined): void { this.selectedVersion = version; if (this.did && version?.dvid) @@ -483,7 +450,7 @@ export class DatasetDetailComponent implements OnInit { this.notificationService.success( `File ${node.name} is successfully deleted. You may finalize it or revert it at the "Create Version" panel` ); - this.versionUploader?.notePathStaged(relativePath); + this.versionsFilesBrowser?.notePathStaged(relativePath); }, error: (err: unknown) => { this.notificationService.error("Failed to delete the file"); @@ -644,19 +611,6 @@ export class DatasetDetailComponent implements OnInit { }); } - async copyCurrentFilePath(): Promise<void> { - if (!this.currentDisplayedFileName) { - return; - } - - try { - await navigator.clipboard.writeText(this.currentDisplayedFileName); - this.notificationService.success("File path copied to clipboard"); - } catch (error) { - this.notificationService.error("Failed to copy file path"); - } - } - onAddContributor(): void { this.openContributorEditor("Add Contributor", null, newContributor => [ ...this.datasetContributors, diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html index ca772155c4..cd40389898 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.html @@ -159,217 +159,37 @@ </div> </nz-tab> <nz-tab nzTitle="Versions & Files"> - <div class="versions-tab-content"> - <nz-layout> - <nz-content 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 - *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="This model has no version yet"></nz-empty> - - <texera-user-dataset-file-renderer - *ngIf="selectedVersion" - [isMaximized]="isMaximized" - [resourceType]="modelEntityType" - [resourceId]="mid" - [versionId]="selectedVersion.mvid" - [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 - 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 this version as ZIP" - (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(currentModelVersionSize) }} - </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]="userHasWriteAccess()" - [isCoverSettable]="userHasWriteAccess()" - (selectedTreeNode)="onVersionFileTreeNodeSelected($event)" - (deletedTreeNode)="onPreviouslyUploadedFileDeleted($event)" - (setCoverImage)="onSetCoverImage($event)"> - </texera-user-dataset-version-filetree> - </nz-collapse-panel> - </nz-collapse> - - <nz-divider *ngIf="userHasWriteAccess()"></nz-divider> - <texera-version-uploader - *ngIf="userHasWriteAccess()" - [resourceId]="mid" - [ownerEmail]="ownerEmail" - [resourceName]="modelName" - [endpoint]="modelEndpoint" - [createVersion]="createModelVersion" - (versionCreated)="onVersionCreated()" - (uploadsInFlightChange)="uploadsInFlight = $event"> - </texera-version-uploader> - </div> - </nz-sider> - </nz-layout> - </div> + <texera-versions-files-browser + [(isMaximized)]="isMaximized" + [(isRightBarCollapsed)]="isRightBarCollapsed" + [currentDisplayedFileName]="currentDisplayedFileName" + [currentFileSize]="currentFileSize" + [currentVersionSize]="currentModelVersionSize" + [selectedVersionCreationTime]="selectedVersionCreationTime" + [versions]="versions" + [selectedVersion]="selectedVersion" + (versionSelected)="onVersionSelected($event)" + [fileTreeNodeList]="fileTreeNodeList" + (fileTreeNodeSelected)="onVersionFileTreeNodeSelected($event)" + (fileDeleted)="onPreviouslyUploadedFileDeleted($event)" + (setCoverImage)="onSetCoverImage($event)" + [resourceId]="mid" + [resourceType]="modelEntityType" + [versionId]="selectedVersion?.mvid" + [ownerEmail]="ownerEmail" + [resourceName]="modelName" + [endpoint]="modelEndpoint" + [createVersion]="createModelVersion" + (versionCreated)="onVersionCreated()" + (uploadsInFlightChange)="uploadsInFlight = $event" + [isLogin]="isLogin" + [writeAccess]="userHasWriteAccess()" + [downloadAllowed]="isDownloadAllowed()" + emptyMessage="This model has no version yet" + downloadZipTooltip="Download this version as ZIP" + (downloadCurrentFile)="onClickDownloadCurrentFile()" + (downloadVersionAsZip)="onClickDownloadVersionAsZip()"> + </texera-versions-files-browser> </nz-tab> <nz-tab nzTitle="Settings" diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts index bb9d0e2900..970bd352f3 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.spec.ts @@ -24,7 +24,6 @@ import { of, Subject, throwError } from "rxjs"; import { MarkdownService } from "ngx-markdown"; import { NzModalService } from "ng-zorro-antd/modal"; import { NgModel } from "@angular/forms"; -import { NzResizableDirective } from "ng-zorro-antd/resizable"; import { By } from "@angular/platform-browser"; import { commonTestImports, commonTestProviders } from "../../../../../common/testing/test-utils"; import { NotificationService } from "../../../../../common/service/notification/notification.service"; @@ -195,9 +194,6 @@ describe("ModelDetailComponent", () => { return el as unknown as E; }; - // The sider coalesces resize events into one write per animation frame. - const nextFrame = (): Promise<void> => new Promise<void>(resolve => requestAnimationFrame(() => resolve())); - // βββ loading the model ββββββββββββββββββββββββββββββββββββββββββββββββββββββ it("reads the mid off the route as a number and loads the model", () => { @@ -459,29 +455,6 @@ describe("ModelDetailComponent", () => { expect(component.isDownloadAllowed()).toBe(true); }); - // βββ the file path clipboard ββββββββββββββββββββββββββββββββββββββββββββββββ - - it("copies the open file's path and reports failure", async () => { - create(); - const writeText = vi.fn(() => Promise.resolve()); - Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); - - render({ currentDisplayedFileName: "/model/a/b/v1/model.pt" }); - await component.copyCurrentFilePath(); - expect(writeText).toHaveBeenCalledWith("/model/a/b/v1/model.pt"); - expect(notificationService["success"]).toHaveBeenCalled(); - - writeText.mockImplementationOnce(() => Promise.reject(new Error("denied"))); - await component.copyCurrentFilePath(); - expect(notificationService["error"]).toHaveBeenCalled(); - - // Nothing open: no clipboard call at all. - writeText.mockClear(); - render({ currentDisplayedFileName: "" }); - await component.copyCurrentFilePath(); - expect(writeText).not.toHaveBeenCalled(); - }); - // βββ template βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ it("renders the model's name, tags and stats", () => { @@ -1021,13 +994,13 @@ describe("ModelDetailComponent", () => { const root = openTab("Versions & Files"); expect(root.querySelector("nz-sider")).not.toBeNull(); - component.onClickHideRightBar(); + // The browser owns the toolbar controls; the page keeps the two flags and feeds them back in. + component.isRightBarCollapsed = true; fixture.detectChanges(); expect(fixture.nativeElement.querySelector("nz-sider")).toBeNull(); - component.onClickScaleTheView(); + component.isMaximized = true; fixture.detectChanges(); - expect(component.isMaximized).toBe(true); expect(fixture.nativeElement.querySelector(".model-header")).toBeNull(); }); @@ -1054,23 +1027,6 @@ describe("ModelDetailComponent", () => { expect(component.currentUid).toBe(42); }); - // βββ the resizable sider ββββββββββββββββββββββββββββββββββββββββββββββββββββ - - it("stores the dragged sider width on the next animation frame", async () => { - create(); - expect(component.siderWidth).toBe(400); - - // A drag emits continuously; the component coalesces to one write per frame. - component.onSideResize({ width: 250, height: 999 }); - - // The write is deferred, not immediate β that deferral is the whole point of the - // requestAnimationFrame hop, and a synchronous assignment would land here. - expect(component.siderWidth).toBe(400); - await nextFrame(); - - expect(component.siderWidth).toBe(250); - }); - // βββ guards against an absent model id ββββββββββββββββββββββββββββββββββββββ it("fetches nothing without a model id", () => { @@ -1369,19 +1325,6 @@ describe("ModelDetailComponent", () => { expect(q<HTMLButtonElement>(fixture.nativeElement, ".spaced-button").disabled).toBe(true); }); - it("wires the sider's resize handle to the width the page keeps", async () => { - oneVersionWithOneFile(); - create(); - openTab("Versions & Files"); - - const sider = fixture.debugElement.query(By.directive(NzResizableDirective)); - expect(sider).not.toBeNull(); - sider.injector.get(NzResizableDirective).nzResize.emit({ width: 275, height: 0 }); - await nextFrame(); - - expect(component.siderWidth).toBe(275); - }); - it("wires the version picker to both the selection and the file tree", () => { const versions = [aVersion(2, "v2"), aVersion(1, "v1")]; modelService["retrieveModelVersionList"] = vi.fn(() => of(versions)); diff --git a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts index 37bab19df4..2b11761d93 100644 --- a/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts +++ b/frontend/src/app/dashboard/component/user/user-model/user-model-explorer/model-detail.component.ts @@ -25,7 +25,6 @@ import { Observable, of } from "rxjs"; import { format } from "date-fns"; import { NgIf, NgClass, NgFor } from "@angular/common"; import { FormsModule } from "@angular/forms"; -import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from "ng-zorro-antd/resizable"; import { NzCardComponent, NzCardMetaComponent } from "ng-zorro-antd/card"; import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; import { NzTagComponent } from "ng-zorro-antd/tag"; @@ -33,9 +32,6 @@ import { Ι΅NzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patc import { NzIconDirective } from "ng-zorro-antd/icon"; import { NzButtonComponent } from "ng-zorro-antd/button"; import { NzWaveDirective } from "ng-zorro-antd/core/wave"; -import { NzLayoutComponent, NzContentComponent, NzSiderComponent } from "ng-zorro-antd/layout"; -import { NzEmptyComponent } from "ng-zorro-antd/empty"; -import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse"; import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select"; import { NzTabsComponent, NzTabComponent } from "ng-zorro-antd/tabs"; import { NzDividerComponent } from "ng-zorro-antd/divider"; @@ -64,9 +60,7 @@ import { getRelativePathFromDatasetFileNode, } from "../../../../../common/type/datasetVersionFileTree"; import { MarkdownDescriptionComponent } from "../../markdown-description/markdown-description.component"; -import { VersionUploaderComponent } from "../../version-uploader/version-uploader.component"; -import { UserDatasetFileRendererComponent } from "../../user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component"; -import { UserDatasetVersionFiletreeComponent } from "../../user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component"; +import { VersionsFilesBrowserComponent } from "../../versions-files-browser/versions-files-browser.component"; @UntilDestroy() @Component({ @@ -85,14 +79,6 @@ import { UserDatasetVersionFiletreeComponent } from "../../user-dataset/user-dat NzIconDirective, NzButtonComponent, NzWaveDirective, - NzLayoutComponent, - NzContentComponent, - NzSiderComponent, - NzResizableDirective, - NzResizeHandleComponent, - NzEmptyComponent, - NzCollapseComponent, - NzCollapsePanelComponent, NzSelectComponent, NzOptionComponent, NzTabsComponent, @@ -101,9 +87,7 @@ import { UserDatasetVersionFiletreeComponent } from "../../user-dataset/user-dat NzInputDirective, NzSwitchComponent, MarkdownDescriptionComponent, - VersionUploaderComponent, - UserDatasetFileRendererComponent, - UserDatasetVersionFiletreeComponent, + VersionsFilesBrowserComponent, ], }) export class ModelDetailComponent implements OnInit { @@ -142,8 +126,10 @@ export class ModelDetailComponent implements OnInit { public likeCount: number = 0; public isLiked: boolean = false; - public isRightBarCollapsed = false; + // The versions/files browser is presentational; its two toolbar flags live here (maximizing + // the file view also hides the page header) and are shared through two-way bindings. public isMaximized = false; + public isRightBarCollapsed = false; public isLogin: boolean = this.userService.isLogin(); public currentUid: number | undefined = this.userService.getCurrentUser()?.uid; @@ -153,7 +139,7 @@ export class ModelDetailComponent implements OnInit { public readonly frameworks = MODEL_FRAMEWORKS; public readonly formats = MODEL_FORMATS; - @ViewChild(VersionUploaderComponent) private versionUploader?: VersionUploaderComponent; + @ViewChild(VersionsFilesBrowserComponent) private versionsFilesBrowser?: VersionsFilesBrowserComponent<ModelVersion>; // Renaming mid-upload strands the in-flight parts under the old name, so the Settings tab // blocks it until the panel is idle. @@ -180,19 +166,6 @@ export class ModelDetailComponent implements OnInit { }); } - // Resizable sider holding the version picker and the file tree. - MAX_SIDER_WIDTH = 600; - MIN_SIDER_WIDTH = 150; - siderWidth = 400; - id = -1; - - onSideResize({ width }: NzResizeEvent): void { - cancelAnimationFrame(this.id); - this.id = requestAnimationFrame(() => { - this.siderWidth = width!; - }); - } - ngOnInit(): void { this.route.params .pipe( @@ -448,26 +421,6 @@ export class ModelDetailComponent implements OnInit { .subscribe(); } - async copyCurrentFilePath(): Promise<void> { - if (!this.currentDisplayedFileName) { - return; - } - try { - await navigator.clipboard.writeText(this.currentDisplayedFileName); - this.notificationService.success("File path copied to clipboard"); - } catch { - this.notificationService.error("Failed to copy file path"); - } - } - - onClickScaleTheView(): void { - this.isMaximized = !this.isMaximized; - } - - onClickHideRightBar(): void { - this.isRightBarCollapsed = !this.isRightBarCollapsed; - } - isDownloadAllowed(): boolean { if (this.isOwner) { return true; @@ -494,7 +447,7 @@ export class ModelDetailComponent implements OnInit { ); // Undefined only when the panel is not rendered, which is the same write-access // condition that gates the tree's delete control. - this.versionUploader?.notePathStaged(relativePath); + this.versionsFilesBrowser?.notePathStaged(relativePath); }, error: () => this.notificationService.error("Failed to delete the file"), }); diff --git a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.html b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.html new file mode 100644 index 0000000000..9926d0b394 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.html @@ -0,0 +1,231 @@ +<!-- + ~ Licensed to the Apache Software Foundation (ASF) under one + ~ or more contributor license agreements. See the NOTICE file + ~ distributed with this work for additional information + ~ regarding copyright ownership. The ASF licenses this file + ~ to you under the Apache License, Version 2.0 (the + ~ "License"); you may not use this file except in compliance + ~ with the License. You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, + ~ software distributed under the License is distributed on an + ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + ~ KIND, either express or implied. See the License for the + ~ specific language governing permissions and limitations + ~ under the License. + --> + +<div class="versions-tab-content"> + <nz-layout> + <nz-content 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 + *ngIf="selectedVersion" + nz-tooltip="Download the file" + [disabled]="!isLogin || !downloadAllowed" + (click)="downloadCurrentFile.emit()"> + <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]="emptyMessage"></nz-empty> + + <texera-user-dataset-file-renderer + *ngIf="selectedVersion" + [isMaximized]="isMaximized" + [resourceType]="resourceType" + [resourceId]="resourceId" + [versionId]="versionId" + [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 + nzPlaceHolder="Select a version" + [ngModel]="selectedVersion" + (ngModelChange)="versionSelected.emit($event)"> + <nz-option + *ngFor="let version of versions" + [nzValue]="version" + [nzLabel]="version.name"></nz-option> + </nz-select> + <button + nz-button + [nz-tooltip]="downloadZipTooltip" + [attr.nz-tooltip]="downloadZipTooltip" + (click)="downloadVersionAsZip.emit()" + *ngIf="selectedVersion" + [disabled]="!isLogin || !downloadAllowed" + 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(currentVersionSize) }} + </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]="writeAccess" + [isCoverSettable]="writeAccess" + (selectedTreeNode)="fileTreeNodeSelected.emit($event)" + (deletedTreeNode)="fileDeleted.emit($event)" + (setCoverImage)="setCoverImage.emit($event)"> + </texera-user-dataset-version-filetree> + </nz-collapse-panel> + </nz-collapse> + + <nz-divider *ngIf="writeAccess"></nz-divider> + <texera-version-uploader + *ngIf="writeAccess" + [resourceId]="resourceId" + [ownerEmail]="ownerEmail" + [resourceName]="resourceName" + [endpoint]="endpoint" + [createVersion]="createVersion" + (versionCreated)="versionCreated.emit()" + (uploadsInFlightChange)="uploadsInFlightChange.emit($event)"> + </texera-version-uploader> + </div> + </nz-sider> + </nz-layout> +</div> diff --git a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.scss b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.scss new file mode 100644 index 0000000000..b1e2c96b26 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.scss @@ -0,0 +1,136 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.versions-tab-content { + height: 100%; + overflow-y: auto; + padding-bottom: 24px; +} + +nz-layout { + height: 100%; +} + +.version-storage { + padding: 0 15px; + margin-bottom: 25px; +} + +.version-storage nz-select { + width: 100%; + margin: 0; +} + +nz-select { + flex-grow: 1; +} + +.right-sider { + height: 100%; + overflow-y: auto; + position: relative; + z-index: 0; +} + +.sider-resize-line { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 5px; + border-right: 1px solid #e8e8e8; +} + +.sider-resize-handle { + background: #fff; + border: 1px solid #ddd; + text-align: center; + font-size: 12px; + height: 20px; + line-height: 20px; +} + +.file-renderer { + width: 95%; + height: 80%; + margin: auto; +} + +.select-and-button-container { + display: flex; + align-items: stretch; + gap: 10px; +} + +.spaced-button { + flex-shrink: 0; + width: 30px; + height: 24px; +} + +.file-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 0; +} + +.file-title-main { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.copy-path-btn { + padding: 0 4px; + height: auto; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.file-size, +.version-size, +.version-date { + font-size: 12px; + color: #8c8c8c; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.version-date { + display: flex; +} + +.version-size { + margin-top: 8px; +} + +.icon-database, +.icon-file { + font-size: 14px; +} + +.empty-version-indicator { + margin-top: 15%; +} diff --git a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.spec.ts b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.spec.ts new file mode 100644 index 0000000000..7b558dede6 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.spec.ts @@ -0,0 +1,182 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { NzResizeEvent } from "ng-zorro-antd/resizable"; +import { NzSelectComponent } from "ng-zorro-antd/select"; +import { VersionsFilesBrowserComponent } from "./versions-files-browser.component"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { commonTestImports, commonTestProviders } from "../../../../common/testing/test-utils"; + +describe("VersionsFilesBrowserComponent", () => { + let fixture: ComponentFixture<VersionsFilesBrowserComponent>; + let component: VersionsFilesBrowserComponent; + let notification: { + success: ReturnType<typeof vi.fn>; + error: ReturnType<typeof vi.fn>; + info: ReturnType<typeof vi.fn>; + }; + + beforeEach(() => { + notification = { success: vi.fn(), error: vi.fn(), info: vi.fn() }; + TestBed.configureTestingModule({ + imports: [VersionsFilesBrowserComponent, NoopAnimationsModule, ...commonTestImports], + providers: [{ provide: NotificationService, useValue: notification }, ...commonTestProviders], + }); + fixture = TestBed.createComponent(VersionsFilesBrowserComponent); + component = fixture.componentInstance; + }); + + it("starts with the default sider bounds and width", () => { + expect(component.siderWidth).toBe(400); + expect(component.MIN_SIDER_WIDTH).toBe(150); + expect(component.MAX_SIDER_WIDTH).toBe(600); + expect(component.MIN_SIDER_WIDTH).toBeLessThan(component.MAX_SIDER_WIDTH); + }); + + // The parent owns both toolbar flags: the browser reports a toggle and never mutates its input. + it("reports a maximize toggle instead of owning the flag", () => { + const emitted: boolean[] = []; + component.isMaximizedChange.subscribe(v => emitted.push(v)); + + component.isMaximized = false; + component.onClickScaleTheView(); + component.isMaximized = true; + component.onClickScaleTheView(); + + expect(emitted).toEqual([true, false]); + expect(component.isMaximized).toBe(true); // input left untouched + }); + + it("reports a right-bar toggle instead of owning the flag", () => { + const emitted: boolean[] = []; + component.isRightBarCollapsedChange.subscribe(v => emitted.push(v)); + + component.isRightBarCollapsed = false; + component.onClickHideRightBar(); + + expect(emitted).toEqual([true]); + expect(component.isRightBarCollapsed).toBe(false); + }); + + it("applies the dragged sider width on the next animation frame", async () => { + component.onSideResize({ width: 321 } as NzResizeEvent); + // the handler defers to requestAnimationFrame; a synchronous write would land here + expect(component.siderWidth).toBe(400); + await new Promise(resolve => requestAnimationFrame(() => resolve(null))); + + expect(component.siderWidth).toBe(321); + }); + + it("cancels the frame the previous resize scheduled", () => { + const request = vi.spyOn(globalThis, "requestAnimationFrame").mockReturnValue(100); + const cancel = vi.spyOn(globalThis, "cancelAnimationFrame"); + try { + component.onSideResize({ width: 100 } as NzResizeEvent); + cancel.mockClear(); // drop the initial cancel of the sentinel id + + component.onSideResize({ width: 200 } as NzResizeEvent); + + expect(cancel).toHaveBeenCalledWith(100); + } finally { + cancel.mockRestore(); + request.mockRestore(); + } + }); + + describe("copyCurrentFilePath", () => { + let originalClipboardDescriptor: PropertyDescriptor | undefined; + let writeText: ReturnType<typeof vi.fn>; + + beforeEach(() => { + originalClipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + }); + + afterEach(() => { + if (originalClipboardDescriptor) { + Object.defineProperty(navigator, "clipboard", originalClipboardDescriptor); + } else { + delete (navigator as any).clipboard; + } + }); + + it("writes the displayed path to the clipboard and toasts success", async () => { + component.currentDisplayedFileName = "/a/b/c.txt"; + + await component.copyCurrentFilePath(); + + expect(writeText).toHaveBeenCalledWith("/a/b/c.txt"); + expect(notification.success).toHaveBeenCalledWith("File path copied to clipboard"); + }); + + it("does nothing when no file is displayed", async () => { + component.currentDisplayedFileName = ""; + + await component.copyCurrentFilePath(); + + expect(writeText).not.toHaveBeenCalled(); + }); + + it("toasts an error when the clipboard write rejects", async () => { + writeText.mockRejectedValue(new Error("denied")); + component.currentDisplayedFileName = "/a/b/c.txt"; + + await component.copyCurrentFilePath(); + + expect(notification.error).toHaveBeenCalledWith("Failed to copy file path"); + }); + }); + + it("delegates notePathStaged to the embedded uploader", () => { + const uploader = { notePathStaged: vi.fn() }; + (component as any).versionUploader = uploader; + + component.notePathStaged("owner/ds/v1/a.txt"); + + expect(uploader.notePathStaged).toHaveBeenCalledWith("owner/ds/v1/a.txt"); + }); + + it("emits the version chosen in the picker", () => { + const v1 = { name: "v1" }; + const v2 = { name: "v2" }; + const picked: Array<{ name: string } | undefined> = []; + component.versions = [v1, v2]; + component.versionSelected.subscribe(v => picked.push(v)); + fixture.detectChanges(); + + const select = fixture.debugElement.query(By.directive(NzSelectComponent)); + select.triggerEventHandler("ngModelChange", v2); + + expect(picked).toEqual([v2]); + }); + + it("shows the empty-state message until a version is selected", () => { + component.emptyMessage = "No version is selected"; + component.selectedVersion = undefined; + fixture.detectChanges(); + + const empty = (fixture.nativeElement as HTMLElement).querySelector(".empty-version-indicator"); + expect(empty).not.toBeNull(); + expect((fixture.nativeElement as HTMLElement).textContent ?? "").toContain("No version is selected"); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.ts b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.ts new file mode 100644 index 0000000000..7acc71532e --- /dev/null +++ b/frontend/src/app/dashboard/component/user/versions-files-browser/versions-files-browser.component.ts @@ -0,0 +1,177 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, EventEmitter, Input, Output, ViewChild } from "@angular/core"; +import { Observable } from "rxjs"; +import { NgIf, NgFor } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { NzResizeEvent, NzResizableDirective, NzResizeHandleComponent } from "ng-zorro-antd/resizable"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { NzTooltipDirective } from "ng-zorro-antd/tooltip"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzLayoutComponent, NzContentComponent, NzSiderComponent } from "ng-zorro-antd/layout"; +import { NzEmptyComponent } from "ng-zorro-antd/empty"; +import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse"; +import { NzSelectComponent, NzOptionComponent } from "ng-zorro-antd/select"; +import { NzDividerComponent } from "ng-zorro-antd/divider"; +import { formatSize } from "src/app/common/util/size-formatter.util"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { EntityType } from "../../../../hub/service/hub.service"; +import { DatasetFileNode } from "../../../../common/type/datasetVersionFileTree"; +import { + FileResourceEndpoint, + DATASET_FILE_RESOURCE_ENDPOINT, +} from "../../../service/user/file-resource/file-resource-endpoint"; +import { UserDatasetFileRendererComponent } from "../user-dataset/user-dataset-explorer/user-dataset-file-renderer/user-dataset-file-renderer.component"; +import { UserDatasetVersionFiletreeComponent } from "../user-dataset/user-dataset-explorer/user-dataset-version-filetree/user-dataset-version-filetree.component"; +import { VersionUploaderComponent } from "../version-uploader/version-uploader.component"; + +/** The only field the version picker needs from a dataset/model version. */ +export interface VersionListEntry { + readonly name: string; +} + +/** + * Presentational "Versions & Files" tab shared by the dataset and model detail pages: a file + * header/toolbar, the file renderer, and a resizable sider holding the version picker, file tree, + * and version uploader. It owns only its local toolbar view state; every server call and the + * version-selection flow stay in the parent, reached through the inputs and outputs below. + */ +@Component({ + selector: "texera-versions-files-browser", + templateUrl: "./versions-files-browser.component.html", + styleUrls: ["./versions-files-browser.component.scss"], + imports: [ + NgIf, + NgFor, + FormsModule, + NzCardComponent, + NzTooltipDirective, + NzIconDirective, + NzButtonComponent, + NzLayoutComponent, + NzContentComponent, + NzSiderComponent, + NzResizableDirective, + NzResizeHandleComponent, + NzEmptyComponent, + NzCollapseComponent, + NzCollapsePanelComponent, + NzSelectComponent, + NzOptionComponent, + NzDividerComponent, + UserDatasetFileRendererComponent, + UserDatasetVersionFiletreeComponent, + VersionUploaderComponent, + ], +}) +export class VersionsFilesBrowserComponent<V extends VersionListEntry = VersionListEntry> { + // File currently shown in the renderer (resolved by the parent). + @Input() currentDisplayedFileName: string = ""; + @Input() currentFileSize: number | undefined; + @Input() currentVersionSize: number | undefined; + @Input() selectedVersionCreationTime: string = ""; + + // Version picker. + @Input() versions: ReadonlyArray<V> = []; + @Input() selectedVersion: V | undefined; + @Output() versionSelected = new EventEmitter<V | undefined>(); + + // File tree. + @Input() fileTreeNodeList: DatasetFileNode[] = []; + @Output() fileTreeNodeSelected = new EventEmitter<DatasetFileNode>(); + @Output() fileDeleted = new EventEmitter<DatasetFileNode>(); + @Output() setCoverImage = new EventEmitter<string>(); + + // File renderer. resourceType defaults to Dataset to match the renderer's own default. + @Input() resourceId: number | undefined; + @Input() resourceType: EntityType = EntityType.Dataset; + @Input() versionId: number | undefined; + + // Version uploader. + @Input() ownerEmail: string = ""; + @Input() resourceName: string = ""; + @Input() endpoint: FileResourceEndpoint = DATASET_FILE_RESOURCE_ENDPOINT; + @Input() createVersion!: (versionName: string) => Observable<unknown>; + @Output() versionCreated = new EventEmitter<void>(); + @Output() uploadsInFlightChange = new EventEmitter<boolean>(); + + // Flags and per-resource labels. + @Input() isLogin: boolean = false; + @Input() writeAccess: boolean = false; + @Input() downloadAllowed: boolean = false; + @Input() emptyMessage: string = ""; + @Input() downloadZipTooltip: string = ""; + + // Toolbar actions handled by the parent (per-resource download endpoints). + @Output() downloadCurrentFile = new EventEmitter<void>(); + @Output() downloadVersionAsZip = new EventEmitter<void>(); + + // The parent owns the two toolbar view flags (maximizing also hides the parent page + // header); the browser reads them and reports toggles back through the two-way bindings. + @Input() isMaximized = false; + @Output() isMaximizedChange = new EventEmitter<boolean>(); + @Input() isRightBarCollapsed = false; + @Output() isRightBarCollapsedChange = new EventEmitter<boolean>(); + + // Local sider width state. + readonly MAX_SIDER_WIDTH = 600; + readonly MIN_SIDER_WIDTH = 150; + siderWidth = 400; + private rafId = -1; + + formatSize = formatSize; + + @ViewChild(VersionUploaderComponent) private versionUploader?: VersionUploaderComponent; + + constructor(private notificationService: NotificationService) {} + + onSideResize({ width }: NzResizeEvent): void { + cancelAnimationFrame(this.rafId); + this.rafId = requestAnimationFrame(() => { + this.siderWidth = width!; + }); + } + + onClickScaleTheView(): void { + this.isMaximizedChange.emit(!this.isMaximized); + } + + onClickHideRightBar(): void { + this.isRightBarCollapsedChange.emit(!this.isRightBarCollapsed); + } + + async copyCurrentFilePath(): Promise<void> { + if (!this.currentDisplayedFileName) { + return; + } + try { + await navigator.clipboard.writeText(this.currentDisplayedFileName); + this.notificationService.success("File path copied to clipboard"); + } catch { + this.notificationService.error("Failed to copy file path"); + } + } + + /** Lets the parent stage a path on the embedded uploader after it deletes the file. */ + notePathStaged(relativePath: string): void { + this.versionUploader?.notePathStaged(relativePath); + } +}
