diff --git a/frontend/src/app/app-routing.constant.ts b/frontend/src/app/app-routing.constant.ts index cea975e683a..3979d3f9e91 100644 --- a/frontend/src/app/app-routing.constant.ts +++ b/frontend/src/app/app-routing.constant.ts @@ -36,6 +36,7 @@ export const USER_DATASET = `${USER}/dataset`; export const USER_DATASET_CREATE = `${USER_DATASET}/create`; export const USER_MODEL = `${USER}/model`; export const USER_COMPUTING_UNIT = `${USER}/compute`; +export const USER_WAREHOUSE = `${USER}/warehouse`; export const USER_PYTHON_VENV = `${USER}/python-venv`; export const USER_QUOTA = `${USER}/quota`; export const USER_DISCUSSION = `${USER}/discussion`; diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index d7f9af1ffae..b24d11be5cf 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -23,6 +23,7 @@ import { DashboardComponent } from "./dashboard/component/dashboard.component"; import { UserWorkflowComponent } from "./dashboard/component/user/user-workflow/user-workflow.component"; import { UserQuotaComponent } from "./dashboard/component/user/user-quota/user-quota.component"; import { UserComputingUnitComponent } from "./dashboard/component/user/user-computing-unit/user-computing-unit.component"; +import { UserWarehouseComponent } from "./dashboard/component/user/user-warehouse/user-warehouse.component"; import { UserVenvComponent } from "./dashboard/component/user/user-venv/user-venv.component"; import { WorkspaceComponent } from "./workspace/component/workspace.component"; import { AboutComponent } from "./hub/component/about/about.component"; @@ -139,6 +140,10 @@ routes.push({ path: "compute", component: UserComputingUnitComponent, }, + { + path: "warehouse", + component: UserWarehouseComponent, + }, { path: "python-venv", component: UserVenvComponent, diff --git a/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.html b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.html new file mode 100644 index 00000000000..a90996a2d62 --- /dev/null +++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.html @@ -0,0 +1,59 @@ + + + + +
+
+ Warehouse Name + +

Letters, digits, '-' and '_' only; must start with a letter or digit.

+
+
+
+ + + + +
diff --git a/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.scss b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.scss new file mode 100644 index 00000000000..4f891f64c27 --- /dev/null +++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.scss @@ -0,0 +1,48 @@ +/** + * 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. + */ + +.create-warehouse-container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + justify-content: start; + align-items: center; +} + +.select-unit { + display: flex; + flex-direction: column; + gap: 10px; + justify-content: center; + align-items: flex-start; +} + +.select-unit.name-field { + grid-column: span 2; +} + +.warehouse-name-input { + width: 100%; +} + +.warehouse-name-hint { + margin: 8px 0 0; + color: rgba(0, 0, 0, 0.45); + font-size: 12px; +} diff --git a/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts new file mode 100644 index 00000000000..1e9bf2c686d --- /dev/null +++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts @@ -0,0 +1,266 @@ +/** + * 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 { SimpleChange } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HttpClientTestingModule } from "@angular/common/http/testing"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { Subject, of, throwError } from "rxjs"; +import { WarehouseCreateModalComponent } from "./warehouse-create-modal.component"; +import { NotificationService } from "../../service/notification/notification.service"; +import { WarehouseActionsService } from "../../service/warehouse/warehouse-actions.service"; +import { DashboardWarehouse } from "../../type/warehouse"; +import { commonTestProviders } from "../../testing/test-utils"; + +describe("WarehouseCreateModalComponent", () => { + let fixture: ComponentFixture; + let component: WarehouseCreateModalComponent; + let warehouseActions: { create: ReturnType }; + let notificationService: { error: ReturnType; success: ReturnType }; + + const created: DashboardWarehouse = { + whid: 7, + name: "mybucket", + lakekeeperWarehouseName: "user-1-mybucket", + flavor: "local", + createdAtMillis: 0, + ownerName: "Alice", + ownerAvatar: "", + }; + + beforeEach(async () => { + warehouseActions = { create: vi.fn().mockReturnValue(of(created)) }; + notificationService = { error: vi.fn(), success: vi.fn() }; + + await TestBed.configureTestingModule({ + imports: [WarehouseCreateModalComponent, NoopAnimationsModule, HttpClientTestingModule], + providers: [ + // The rendered injects NzModalService itself. + NzModalService, + { provide: WarehouseActionsService, useValue: warehouseActions }, + { provide: NotificationService, useValue: notificationService }, + ...commonTestProviders, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WarehouseCreateModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture?.destroy(); + }); + + it("renders nothing while closed", () => { + // detectChanges already ran in beforeEach with visible=false. + expect(document.querySelector("#confirm-create-warehouse-btn")).toBeNull(); + }); + + it("renders the name input and a disabled Create button once opened", () => { + component.visible = true; + fixture.detectChanges(); + + // nz-modal renders into the CDK overlay on document, not into the fixture. + expect(document.querySelector("input[nz-input]")).toBeTruthy(); + const createButton = document.querySelector("#confirm-create-warehouse-btn"); + expect(createButton?.disabled).toBe(true); + + component.newWarehouseName = "mybucket"; + fixture.detectChanges(); + expect(createButton?.disabled).toBe(false); + }); + + it("drives create from the dialog's own controls, not just the component method", async () => { + // The buttons and the Enter key are the only paths a user has; asserting on + // the component method alone leaves those bindings unverified. + component.visible = true; + component.newWarehouseName = "mybucket"; + fixture.detectChanges(); + + document.querySelector("#confirm-create-warehouse-btn")!.click(); + expect(warehouseActions.create).toHaveBeenCalledWith("mybucket"); + + warehouseActions.create.mockClear(); + component.visible = true; + component.newWarehouseName = "again"; + fixture.detectChanges(); + document + .querySelector("input[nz-input]")! + .dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })); + expect(warehouseActions.create).toHaveBeenCalledWith("again"); + }); + + it("takes what the user types through the two-way binding", async () => { + component.visible = true; + fixture.detectChanges(); + + const input = document.querySelector("input[nz-input]")!; + input.value = "typed-in"; + input.dispatchEvent(new Event("input", { bubbles: true })); + await fixture.whenStable(); + + expect(component.newWarehouseName).toBe("typed-in"); + }); + + it("closes from the dialog's Cancel button", () => { + const visibleSpy = vi.fn(); + component.visibleChange.subscribe(visibleSpy); + component.visible = true; + fixture.detectChanges(); + + const cancel = Array.from(document.querySelectorAll("button")).find( + b => b.textContent?.trim() === "Cancel" + )!; + cancel.click(); + + expect(component.visible).toBe(false); + expect(visibleSpy).toHaveBeenCalledWith(false); + expect(warehouseActions.create).not.toHaveBeenCalled(); + }); + + it("leaves the form alone when a change does not open the dialog", () => { + component.newWarehouseName = "typing"; + + component.ngOnChanges({}); + + expect(component.newWarehouseName).toBe("typing"); + }); + + it("creates the trimmed name, then emits the warehouse and closes", () => { + const createdSpy = vi.fn(); + const visibleSpy = vi.fn(); + component.warehouseCreated.subscribe(createdSpy); + component.visibleChange.subscribe(visibleSpy); + component.visible = true; + component.newWarehouseName = " mybucket "; + + component.createWarehouse(); + + expect(warehouseActions.create).toHaveBeenCalledWith("mybucket"); + expect(notificationService.success).toHaveBeenCalledWith('Warehouse "mybucket" created.'); + expect(createdSpy).toHaveBeenCalledWith(created); + expect(component.visible).toBe(false); + expect(visibleSpy).toHaveBeenCalledWith(false); + expect(component.creating).toBe(false); + }); + + it("does nothing for a blank name", () => { + component.newWarehouseName = " "; + + component.createWarehouse(); + + expect(warehouseActions.create).not.toHaveBeenCalled(); + }); + + it("does not double-submit while a create is in flight", () => { + component.newWarehouseName = "mybucket"; + component.creating = true; + + component.createWarehouse(); + + expect(warehouseActions.create).not.toHaveBeenCalled(); + }); + + it("keeps the modal open and surfaces the backend message when the create fails", () => { + warehouseActions.create.mockReturnValue( + throwError(() => ({ error: "a warehouse named 'mybucket' already exists" })) + ); + const visibleSpy = vi.fn(); + component.visibleChange.subscribe(visibleSpy); + component.visible = true; + component.newWarehouseName = "mybucket"; + + component.createWarehouse(); + + expect(component.visible).toBe(true); + expect(visibleSpy).not.toHaveBeenCalled(); + expect(component.creating).toBe(false); + expect(notificationService.error).toHaveBeenCalledWith( + "Failed to create warehouse: a warehouse named 'mybucket' already exists" + ); + }); + + it("clears the previous name and any stuck loading state when the modal opens", () => { + component.newWarehouseName = "leftover"; + // Cancelling mid-flight leaves creating set; reopening must not show a Create + // button stuck in its loading state. + component.creating = true; + component.visible = true; + + component.ngOnChanges({ visible: new SimpleChange(false, true, false) }); + + expect(component.newWarehouseName).toBe(""); + expect(component.creating).toBe(false); + }); + + it("cancel abandons an in-flight create instead of letting it land later", () => { + // The component outlives the dialog, so without an explicit teardown the + // request would still succeed: creating the warehouse the user cancelled and + // closing the dialog they had already reopened. + const inFlight = new Subject(); + warehouseActions.create.mockReturnValue(inFlight.asObservable()); + const createdSpy = vi.fn(); + component.warehouseCreated.subscribe(createdSpy); + component.visible = true; + component.newWarehouseName = "first"; + component.createWarehouse(); + + component.handleCreateWarehouseModalCancel(); + inFlight.next(created); + inFlight.complete(); + + expect(createdSpy).not.toHaveBeenCalled(); + expect(notificationService.success).not.toHaveBeenCalled(); + expect(component.creating).toBe(false); + }); + + it("a host-driven close abandons the in-flight create like Cancel does", () => { + // The workspace picker (#7817) will close this dialog by flipping + // [(visible)] itself, without going through the Cancel handler. + const inFlight = new Subject(); + warehouseActions.create.mockReturnValue(inFlight.asObservable()); + const createdSpy = vi.fn(); + component.warehouseCreated.subscribe(createdSpy); + component.visible = true; + component.newWarehouseName = "first"; + component.createWarehouse(); + + component.visible = false; + component.ngOnChanges({ visible: new SimpleChange(true, false, false) }); + inFlight.next(created); + inFlight.complete(); + + expect(createdSpy).not.toHaveBeenCalled(); + expect(notificationService.success).not.toHaveBeenCalled(); + }); + + it("cancel closes without creating", () => { + const visibleSpy = vi.fn(); + component.visibleChange.subscribe(visibleSpy); + component.visible = true; + + component.handleCreateWarehouseModalCancel(); + + expect(component.visible).toBe(false); + expect(visibleSpy).toHaveBeenCalledWith(false); + expect(warehouseActions.create).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts new file mode 100644 index 00000000000..3f5419b0725 --- /dev/null +++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts @@ -0,0 +1,122 @@ +/** + * 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, OnChanges, Output, SimpleChanges } from "@angular/core"; +import { FormsModule } from "@angular/forms"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { Subject, takeUntil } from "rxjs"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { NzInputDirective } from "ng-zorro-antd/input"; +import { NzModalComponent } from "ng-zorro-antd/modal"; +import { NotificationService } from "../../service/notification/notification.service"; +import { WarehouseActionsService } from "../../service/warehouse/warehouse-actions.service"; +import { DashboardWarehouse } from "../../type/warehouse"; +import { extractErrorMessage } from "../../util/error"; + +/** + * Shared create-warehouse modal (#6933), embedded the same way + * ComputingUnitCreateModalComponent is — two-way `[(visible)]` controls the + * dialog and `(warehouseCreated)` returns the created warehouse — by the + * dashboard tab today and by the workspace picker once it lands (#7817). + */ +@UntilDestroy() +@Component({ + selector: "texera-warehouse-create-modal", + templateUrl: "./warehouse-create-modal.component.html", + styleUrls: ["./warehouse-create-modal.component.scss"], + imports: [ + FormsModule, + NzModalComponent, + NzButtonComponent, + NzWaveDirective, + ɵNzTransitionPatchDirective, + NzInputDirective, + ], +}) +export class WarehouseCreateModalComponent implements OnChanges { + // Must be bound two-way ([(visible)]): the modal closes itself. + @Input() visible = false; + @Output() visibleChange = new EventEmitter(); + @Output() warehouseCreated = new EventEmitter(); + + newWarehouseName = ""; + creating = false; + + // Closing the dialog ends the attempt it was showing. Without this the request + // outlives the dialog (the component itself is never torn down), so Cancel + // would still create the warehouse and a late response would close — and + // discard — whatever the user had typed after reopening. + private readonly closed$ = new Subject(); + + constructor( + private warehouseActionsService: WarehouseActionsService, + private notificationService: NotificationService + ) {} + + ngOnChanges(changes: SimpleChanges): void { + if (changes["visible"]?.currentValue === true) { + this.newWarehouseName = ""; + // Cancelling mid-flight leaves creating set; without this the Create button + // reopens stuck in its loading state. + this.creating = false; + } else if (changes["visible"]?.currentValue === false) { + // The host can also close the dialog by flipping [(visible)] itself; that + // close must abandon the in-flight attempt exactly like Cancel does, or a + // late response would close — and discard — a reopened dialog. + this.closed$.next(); + } + } + + createWarehouse(): void { + const name = this.newWarehouseName.trim(); + if (!name || this.creating) { + return; + } + this.creating = true; + this.warehouseActionsService + .create(name) + .pipe(takeUntil(this.closed$), untilDestroyed(this)) + .subscribe({ + next: created => { + this.creating = false; + this.notificationService.success(`Warehouse "${created.name}" created.`); + this.warehouseCreated.emit(created); + this.closeModal(); + }, + error: (err: unknown) => { + // Keep the modal open so the name can be corrected. + this.creating = false; + this.notificationService.error(`Failed to create warehouse: ${extractErrorMessage(err)}`); + }, + }); + } + + handleCreateWarehouseModalCancel(): void { + this.closeModal(); + } + + private closeModal(): void { + this.closed$.next(); + this.creating = false; + this.visible = false; + this.visibleChange.emit(false); + } +} diff --git a/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.spec.ts b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.spec.ts new file mode 100644 index 00000000000..dbb294b71e0 --- /dev/null +++ b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.spec.ts @@ -0,0 +1,70 @@ +/** + * 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 { TestBed } from "@angular/core/testing"; +import { NZ_MODAL_DATA } from "ng-zorro-antd/modal"; +import { WarehouseMetadataComponent } from "./warehouse-metadata.component"; +import { DashboardWarehouse } from "../../type/warehouse"; + +describe("WarehouseMetadataComponent", () => { + const warehouse: DashboardWarehouse = { + whid: 3, + name: "sales", + lakekeeperWarehouseName: "user-1-sales", + flavor: "local", + createdAtMillis: 1723300000000, + ownerName: "Alice", + ownerAvatar: "", + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WarehouseMetadataComponent], + providers: [{ provide: NZ_MODAL_DATA, useValue: warehouse }], + }).compileComponents(); + }); + + it("renders every field of the injected warehouse", () => { + const fixture = TestBed.createComponent(WarehouseMetadataComponent); + fixture.detectChanges(); + + const text = fixture.nativeElement.textContent; + expect(text).toContain("sales"); + expect(text).toContain("Alice"); + expect(text).toContain("local"); + // The absolute timestamp is locale-dependent; assert against the same + // conversion the component performs rather than a hard-coded string. + expect(text).toContain(new Date(1723300000000).toLocaleString()); + }); + + it("shows None for an owner with no display name", () => { + TestBed.overrideProvider(NZ_MODAL_DATA, { useValue: { ...warehouse, ownerName: null } }); + const fixture = TestBed.createComponent(WarehouseMetadataComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toContain("None"); + }); + + it("leaves out the catalog name, which locates data the user cannot reach yet", () => { + const fixture = TestBed.createComponent(WarehouseMetadataComponent); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toContain("user-1-sales"); + }); +}); diff --git a/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.ts b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.ts new file mode 100644 index 00000000000..83d9abcce50 --- /dev/null +++ b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.ts @@ -0,0 +1,61 @@ +/** + * 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, inject } from "@angular/core"; +import { NZ_MODAL_DATA } from "ng-zorro-antd/modal"; +import { DashboardWarehouse } from "../../type/warehouse"; + +/** + * Read-only warehouse details dialog (#6933), mirroring + * ComputingUnitMetadataComponent. Opened via NzModalService with the + * warehouse as NZ_MODAL_DATA. + * + * The Lakekeeper catalog name is deliberately left out: it is an internal + * catalog identifier that happens to double as the storage key prefix. What a + * user could act on, once warehouses live in their own bucket (#6870, Phase 1), + * is the storage location itself — a separate field. + */ +@Component({ + template: ` + + + + + + + + + + + + + + + + + + + +
Name{{ warehouse.name }}
Owner{{ warehouse.ownerName || "None" }}
Flavor{{ warehouse.flavor }}
Created{{ createdAt }}
+ `, +}) +export class WarehouseMetadataComponent { + readonly warehouse: DashboardWarehouse = inject(NZ_MODAL_DATA); + readonly createdAt = new Date(this.warehouse.createdAtMillis).toLocaleString(); +} diff --git a/frontend/src/app/dashboard/component/dashboard.component.html b/frontend/src/app/dashboard/component/dashboard.component.html index e0c790ca56d..3cee70d8575 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.html +++ b/frontend/src/app/dashboard/component/dashboard.component.html @@ -108,6 +108,18 @@ nzType="deployment-unit"> Compute +
  • + + Warehouses +
  • { expect(USER_WORKFLOW).toBe("/user/workflow"); expect(USER_DATASET).toBe("/user/dataset"); expect(USER_COMPUTING_UNIT).toBe("/user/compute"); + expect(USER_WAREHOUSE).toBe("/user/warehouse"); expect(USER_QUOTA).toBe("/user/quota"); expect(USER_DISCUSSION).toBe("/user/discussion"); expect(ADMIN_USER).toBe("/admin/user"); @@ -296,6 +299,66 @@ describe("DashboardComponent", () => { expect(fixture.debugElement.queryAll(By.directive(RouterLink)).length).toBe(13); }); + describe("warehouse tab gating (#6933)", () => { + const warehouseMenuItem = () => + fixture.debugElement + .queryAll(By.css("li[nz-menu-item]")) + .find(de => (de.nativeElement.textContent || "").trim() === "Warehouses"); + + beforeEach(() => { + (userServiceMock.isLogin as Mock).mockReturnValue(true); + component.isLogin = true; + component.sidebarTabs = { ...component.sidebarTabs, your_work_enabled: true }; + }); + + it("shows the Warehouses item only while the backend reports the feature enabled", () => { + component.warehouseEnabled = false; + fixture.detectChanges(); + expect(warehouseMenuItem()).toBeUndefined(); + + component.warehouseEnabled = true; + fixture.detectChanges(); + expect(warehouseMenuItem()).toBeTruthy(); + }); + + it("asks the status endpoint exactly once per dashboard load", () => { + // userChanged() replays the current user synchronously (a ReplaySubject(1) + // primed in UserService's constructor), so that subscription already covers + // the initial load: a second explicit call in ngOnInit would double every + // dashboard load, and dropping both would leave the tab permanently hidden. + (userServiceMock.isLogin as Mock).mockReturnValue(true); + const statusSpy = vi + .spyOn(TestBed.inject(WarehouseService), "getStatus") + .mockReturnValue(of({ enabled: true, warehouses: [] })); + + const freshFixture = TestBed.createComponent(DashboardComponent); + freshFixture.detectChanges(); + + expect(statusSpy).toHaveBeenCalledTimes(1); + expect(freshFixture.componentInstance.warehouseEnabled).toBe(true); + freshFixture.destroy(); + }); + + it("loadWarehouseEnabled follows the status endpoint, and stays hidden when logged out or failing", () => { + const statusSpy = vi + .spyOn(TestBed.inject(WarehouseService), "getStatus") + .mockReturnValue(of({ enabled: true, warehouses: [] })); + + component.isLogin = false; + component.loadWarehouseEnabled(); + expect(statusSpy).not.toHaveBeenCalled(); + expect(component.warehouseEnabled).toBe(false); + + component.isLogin = true; + component.loadWarehouseEnabled(); + expect(component.warehouseEnabled).toBe(true); + + statusSpy.mockReturnValue(throwError(() => new Error("401"))); + component.loadWarehouseEnabled(); + expect(component.warehouseEnabled).toBe(false); + }); + }); + describe("sidebar active-route highlighting (#3490)", () => { const fullSidebarTabs = { hub_enabled: false, diff --git a/frontend/src/app/dashboard/component/dashboard.component.ts b/frontend/src/app/dashboard/component/dashboard.component.ts index 79d371db117..25f1ecbddaa 100644 --- a/frontend/src/app/dashboard/component/dashboard.component.ts +++ b/frontend/src/app/dashboard/component/dashboard.component.ts @@ -25,6 +25,7 @@ import { HttpErrorResponse } from "@angular/common/http"; import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterOutlet } from "@angular/router"; import { HubComponent } from "../../hub/component/hub.component"; import { AdminSettingsService } from "../service/admin/settings/admin-settings.service"; +import { WarehouseService } from "../../common/service/warehouse/warehouse.service"; import { GuiConfigService } from "../../common/service/gui-config.service"; import { @@ -39,6 +40,7 @@ import { USER_DISCUSSION, USER_PYTHON_VENV, USER_QUOTA, + USER_WAREHOUSE, USER_WORKFLOW, USER_FEEDBACK, LOGIN, @@ -113,12 +115,17 @@ export class DashboardComponent implements OnInit { about_enabled: false, }; + // Unlike sidebarTabs (admin-configured), the Warehouses tab follows the + // backend's warehouse feature flag, reported by GET /warehouse/status (#6933). + warehouseEnabled = false; + protected readonly LOGIN = LOGIN; protected readonly USER_WORKFLOW = USER_WORKFLOW; protected readonly USER_DATASET = USER_DATASET; protected readonly USER_MODEL = USER_MODEL; protected readonly MODEL_ICON = MODEL_ICON; protected readonly USER_COMPUTING_UNIT = USER_COMPUTING_UNIT; + protected readonly USER_WAREHOUSE = USER_WAREHOUSE; protected readonly USER_PYTHON_VENV = USER_PYTHON_VENV; protected readonly USER_QUOTA = USER_QUOTA; protected readonly USER_DISCUSSION = USER_DISCUSSION; @@ -137,7 +144,8 @@ export class DashboardComponent implements OnInit { private ngZone: NgZone, private route: ActivatedRoute, private adminSettingsService: AdminSettingsService, - protected config: GuiConfigService + protected config: GuiConfigService, + private warehouseService: WarehouseService ) {} ngOnInit(): void { @@ -162,6 +170,7 @@ export class DashboardComponent implements OnInit { this.isLogin = this.userService.isLogin(); this.isAdmin = this.userService.isAdmin(); this.forumLogin(); + this.loadWarehouseEnabled(); }); }); @@ -170,6 +179,26 @@ export class DashboardComponent implements OnInit { this.loadTabs(); } + // The status endpoint needs an authenticated user; logged out (or on any + // fetch failure) the tab simply stays hidden. + loadWarehouseEnabled(): void { + if (!this.isLogin) { + this.warehouseEnabled = false; + return; + } + this.warehouseService + .getStatus() + .pipe(untilDestroyed(this)) + .subscribe({ + next: status => { + this.warehouseEnabled = status.enabled; + }, + error: () => { + this.warehouseEnabled = false; + }, + }); + } + // A missing key or a failed settings fetch keeps the branding/tab defaults; // the error callbacks stop a single failed shared request from surfacing as // one unhandled RxJS error per subscription. diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.html b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.html new file mode 100644 index 00000000000..f3599fc5e2a --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.html @@ -0,0 +1,86 @@ + + + +
    +
    + +
    + +
    + +
    + #{{ warehouse.whid }} +
    + +
    +
    + {{ warehouse.name }} +
    +
    + +
    + +
    + +
    + Created:
    + {{ formatRelativeTime(warehouse.createdAtMillis) }} +
    + +
    + Flavor:
    + {{ warehouse.flavor }} +
    +
    +
    diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss new file mode 100644 index 00000000000..2f087ef2ff6 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss @@ -0,0 +1,105 @@ +/** + * 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. + */ + +@use "../../../section-style" as *; +@use "../../../dashboard.component.scss" as *; + +.warehouse-list-item-card { + padding: 3px; + width: 100%; + background-color: white; + position: relative; + min-height: 65px; + height: auto; + + &:hover { + background-color: #f0f0f0; + } +} + +// The computing-unit row is 64px of content: its two 32px metric bars drive +// the height. A warehouse row has no metrics, so pin the same content height +// here — card paddings and borders already match, so the rows line up exactly. +.warehouse-item-row { + min-height: 64px; +} + +.warehouse-list-item-card:hover .button-group { + display: flex; + background-color: transparent; +} + +.type-icon { + font-size: 30px; +} + +.warehouse-id { + padding: 6px; +} + +.resource-name-group { + min-width: 0; +} + +.resource-name { + font-size: 17px; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +.resource-name:hover { + text-decoration: underline; +} + +.resource-info { + font-size: 13px; + color: grey; +} + +.truncate-single-line { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.button-group { + display: none; + position: absolute; + height: 70px; + min-width: 150px; + right: 0; + bottom: 0; + justify-content: right; + align-items: center; + transition: none; + z-index: 10; + + button { + margin-right: 32px; + transition: none; + background-color: #e0e0e0; + border: 1px solid #d0d0d0; + border-radius: 8px; + } + + button:hover { + background-color: #c7c7c7; + } +} diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.spec.ts new file mode 100644 index 00000000000..d62a168d6ac --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.spec.ts @@ -0,0 +1,98 @@ +/** + * 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 { NzIconModule } from "ng-zorro-antd/icon"; +import { NzModalRef, NzModalService } from "ng-zorro-antd/modal"; +import { CloudServerOutline, DeleteOutline } from "@ant-design/icons-angular/icons"; +import { UserWarehouseListItemComponent } from "./user-warehouse-list-item.component"; +import { WarehouseMetadataComponent } from "../../../../../common/component/warehouse-metadata/warehouse-metadata.component"; +import { DashboardWarehouse } from "../../../../../common/type/warehouse"; +import { commonTestProviders } from "../../../../../common/testing/test-utils"; + +describe("UserWarehouseListItemComponent", () => { + let fixture: ComponentFixture; + + const warehouse: DashboardWarehouse = { + whid: 3, + name: "sales", + lakekeeperWarehouseName: "user-1-sales", + flavor: "local", + createdAtMillis: 0, + ownerName: "Alice", + ownerAvatar: "", + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserWarehouseListItemComponent, NzIconModule.forChild([CloudServerOutline, DeleteOutline])], + providers: [NzModalService, ...commonTestProviders], + }).compileComponents(); + }); + + it("throws when rendered without a warehouse", () => { + const item = new UserWarehouseListItemComponent({} as NzModalService); + + expect(() => item.warehouse).toThrowError("warehouse property must be provided to UserWarehouseListItemComponent."); + }); + + it("renders the id, the name, and the metadata columns", () => { + fixture = TestBed.createComponent(UserWarehouseListItemComponent); + fixture.componentInstance.warehouse = warehouse; + fixture.detectChanges(); + + const element: HTMLElement = fixture.nativeElement; + expect(element.querySelector(".warehouse-id")?.textContent).toContain("#3"); + expect(element.querySelector(".resource-name")?.textContent).toContain("sales"); + // The relative "Created" value depends on the clock; assert the labels only. + expect(element.textContent).toContain("Created:"); + expect(element.textContent).toContain("Flavor:"); + expect(element.textContent).toContain("local"); + }); + + it("emits deleted when the delete button is clicked", () => { + fixture = TestBed.createComponent(UserWarehouseListItemComponent); + fixture.componentInstance.warehouse = warehouse; + fixture.detectChanges(); + const deletedSpy = vi.fn(); + fixture.componentInstance.deleted.subscribe(deletedSpy); + + fixture.debugElement.query(By.css(".button-group button")).triggerEventHandler("click", null); + + expect(deletedSpy).toHaveBeenCalledTimes(1); + }); + + it("opens the metadata modal when the name is clicked", () => { + fixture = TestBed.createComponent(UserWarehouseListItemComponent); + fixture.componentInstance.warehouse = warehouse; + fixture.detectChanges(); + const createSpy = vi.spyOn(TestBed.inject(NzModalService), "create").mockReturnValue({} as NzModalRef); + + fixture.debugElement.query(By.css(".resource-name")).triggerEventHandler("click", null); + + expect(createSpy).toHaveBeenCalledTimes(1); + const config = createSpy.mock.calls[0][0]; + expect(config.nzTitle).toBe("Warehouse Information"); + // The content component is what the dialog actually shows. + expect(config.nzContent).toBe(WarehouseMetadataComponent); + expect(config.nzData).toEqual(warehouse); + expect(config.nzFooter).toBeNull(); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts new file mode 100644 index 00000000000..dcaa780a3c1 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts @@ -0,0 +1,81 @@ +/** + * 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 } from "@angular/core"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { WarehouseMetadataComponent } from "../../../../../common/component/warehouse-metadata/warehouse-metadata.component"; +import { DashboardWarehouse } from "../../../../../common/type/warehouse"; +import { formatRelativeTime } from "../../../../../common/util/format.util"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzRowDirective, NzColDirective } from "ng-zorro-antd/grid"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; + +/** + * One warehouse row of the dashboard tab (#6933), mirroring + * UserComputingUnitListItemComponent: the row only emits `deleted`; the + * containing list owns the confirmation dialog. + */ +@Component({ + selector: "texera-user-warehouse-list-item", + templateUrl: "./user-warehouse-list-item.component.html", + styleUrls: ["./user-warehouse-list-item.component.scss"], + imports: [ + NzCardComponent, + NzRowDirective, + NzColDirective, + ɵNzTransitionPatchDirective, + NzIconDirective, + NzSpaceCompactItemDirective, + NzButtonComponent, + ], +}) +export class UserWarehouseListItemComponent { + private _warehouse?: DashboardWarehouse; + @Output() deleted = new EventEmitter(); + + @Input() + get warehouse(): DashboardWarehouse { + if (!this._warehouse) { + throw new Error("warehouse property must be provided to UserWarehouseListItemComponent."); + } + return this._warehouse; + } + + set warehouse(value: DashboardWarehouse) { + this._warehouse = value; + } + + constructor(private modalService: NzModalService) {} + + openWarehouseMetadataModal(): void { + this.modalService.create({ + nzTitle: "Warehouse Information", + nzContent: WarehouseMetadataComponent, + nzData: this.warehouse, + nzFooter: null, + nzMaskClosable: true, + nzWidth: "600px", + }); + } + + formatRelativeTime = formatRelativeTime; +} diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.html b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.html new file mode 100644 index 00000000000..5de331f13a9 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.html @@ -0,0 +1,84 @@ + + +
    + +

    Warehouses

    +
    + +
    +
    + + +
    + Could not load your warehouses. + +
    + +
    + Per-user warehouses are disabled in this deployment. +
    + +
    + No warehouses yet. Click Create Warehouse to create one. +
    + + + + + + + + +
    +
    + + + diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.scss b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.scss new file mode 100644 index 00000000000..95cecefe8c0 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.scss @@ -0,0 +1,37 @@ +/** + * 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. + */ + +@use "../../dashboard.component.scss" as *; +@use "../../section-style" as *; +@use "../../button-style" as *; + +.subsection-grid-container { + min-width: 100%; + width: 100%; + min-height: 100%; + height: 100%; +} + +.warehouse-page-empty { + padding: 24px; + color: rgba(0, 0, 0, 0.55); + text-align: center; + border: 1px dashed #d9d9d9; + border-radius: 4px; +} diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.spec.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.spec.ts new file mode 100644 index 00000000000..4c5c5ac0dd1 --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.spec.ts @@ -0,0 +1,282 @@ +/** + * 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 { CloudServerOutline, DeleteOutline, FileAddOutline } from "@ant-design/icons-angular/icons"; +import { NzIconModule } from "ng-zorro-antd/icon"; +import { NzModalService } from "ng-zorro-antd/modal"; +import { NEVER, Subject, of, throwError } from "rxjs"; + +import { UserWarehouseComponent } from "./user-warehouse.component"; +import { WarehouseCreateModalComponent } from "../../../../common/component/warehouse-create-modal/warehouse-create-modal.component"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { WarehouseActionsService } from "../../../../common/service/warehouse/warehouse-actions.service"; +import { WarehouseService } from "../../../../common/service/warehouse/warehouse.service"; +import { DashboardWarehouse } from "../../../../common/type/warehouse"; +import { commonTestProviders } from "../../../../common/testing/test-utils"; + +describe("UserWarehouseComponent", () => { + let component: UserWarehouseComponent; + let fixture: ComponentFixture; + + let warehouseServiceSpy: { + getStatus: ReturnType; + createWarehouse: ReturnType; + deleteWarehouse: ReturnType; + }; + let notificationSpy: { + error: ReturnType; + success: ReturnType; + info: ReturnType; + warning: ReturnType; + }; + let consoleErrorSpy: ReturnType; + + const warehouse = (whid: number, name: string): DashboardWarehouse => ({ + whid, + name, + lakekeeperWarehouseName: `user-1-${name}`, + flavor: "local", + createdAtMillis: 1723300000000, + ownerName: "Alice", + ownerAvatar: "", + }); + + beforeEach(async () => { + warehouseServiceSpy = { + getStatus: vi.fn().mockReturnValue(of({ enabled: true, warehouses: [] })), + createWarehouse: vi.fn().mockReturnValue(of(warehouse(1, "mybucket"))), + deleteWarehouse: vi.fn().mockReturnValue(of(undefined)), + }; + notificationSpy = { + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + }; + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await TestBed.configureTestingModule({ + imports: [ + UserWarehouseComponent, + NoopAnimationsModule, + NzIconModule.forChild([FileAddOutline, CloudServerOutline, DeleteOutline]), + ], + providers: [ + NzModalService, + { provide: WarehouseService, useValue: warehouseServiceSpy as unknown as WarehouseService }, + { provide: NotificationService, useValue: notificationSpy as unknown as NotificationService }, + ...commonTestProviders, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(UserWarehouseComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + consoleErrorSpy?.mockRestore(); + fixture?.destroy(); + }); + + it("shows the disabled notice when the deployment reports the feature off", () => { + warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: false, warehouses: [] })); + + fixture.detectChanges(); + + expect(component.warehouseEnabled).toBe(false); + const notice = fixture.nativeElement.querySelector(".warehouse-page-empty"); + expect(notice?.textContent).toContain("disabled in this deployment"); + expect(fixture.nativeElement.querySelectorAll("texera-user-warehouse-list-item").length).toBe(0); + }); + + it("says nothing about the feature while the status request is still in flight", () => { + // A plain false would render the "disabled in this deployment" notice, naming + // the wrong cause while the request is pending or after it failed. + warehouseServiceSpy.getStatus.mockReturnValue(NEVER); + + fixture.detectChanges(); + + expect(component.warehouseEnabled).toBeUndefined(); + expect(fixture.nativeElement.querySelector(".warehouse-page-empty")).toBeNull(); + }); + + it("shows the create hint while the user has no warehouses", () => { + fixture.detectChanges(); + + const notice = fixture.nativeElement.querySelector(".warehouse-page-empty"); + expect(notice?.textContent).toContain("No warehouses yet"); + expect(fixture.nativeElement.querySelectorAll("texera-user-warehouse-list-item").length).toBe(0); + }); + + it("lists the user's warehouses in the virtual-scroll list", () => { + warehouseServiceSpy.getStatus.mockReturnValue( + of({ enabled: true, warehouses: [warehouse(1, "first"), warehouse(2, "second")] }) + ); + + fixture.detectChanges(); + + // The virtual-scroll viewport has no height in jsdom, so no rows are laid + // out here; the row markup is covered by the list item's own spec. What this + // asserts is the state the template iterates and the absence of any notice. + expect(component.warehouses.map(w => w.whid)).toEqual([1, 2]); + expect(fixture.nativeElement.querySelector(".warehouse-page-empty")).toBeNull(); + }); + + it("renders the scroll viewport only when there are rows to show", () => { + // In the disabled/failed/empty states the viewport would otherwise render + // as an empty bordered box below the notice. + warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: false, warehouses: [] })); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("cdk-virtual-scroll-viewport")).toBeNull(); + + warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [warehouse(1, "first")] })); + component.retry(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("cdk-virtual-scroll-viewport")).toBeTruthy(); + }); + + it("opens the create modal from the header button, which is disabled while the feature is off", () => { + warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: false, warehouses: [] })); + fixture.detectChanges(); + + const createButton = fixture.debugElement.query(By.css(".create-btn")); + expect(createButton.nativeElement.disabled).toBe(true); + + warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [] })); + component.ngOnInit(); + fixture.detectChanges(); + expect(createButton.nativeElement.disabled).toBe(false); + + createButton.triggerEventHandler("click", null); + fixture.detectChanges(); + + expect(component.addWarehouseModalVisible).toBe(true); + // The flag has to reach the embedded modal, not just the component field. + expect(fixture.debugElement.query(By.directive(WarehouseCreateModalComponent)).componentInstance.visible).toBe( + true + ); + }); + + it("offers a retry when the status request fails, and recovers on success", () => { + warehouseServiceSpy.getStatus.mockReturnValue(throwError(() => new Error("boom"))); + fixture.detectChanges(); + + expect(component.loadFailed).toBe(true); + const notice = fixture.nativeElement.querySelector(".warehouse-page-empty"); + expect(notice?.textContent).toContain("Could not load your warehouses"); + + warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [warehouse(1, "first")] })); + fixture.debugElement.query(By.css(".warehouse-page-empty button")).triggerEventHandler("click", null); + fixture.detectChanges(); + + expect(component.loadFailed).toBe(false); + expect(component.warehouses.map(w => w.whid)).toEqual([1]); + }); + + it("clears the stale status when a later refresh fails, leaving only the retry state", () => { + // Otherwise a failed refresh right after a delete would show the failure + // notice beside an outdated list still containing the deleted row, with + // Create still enabled. + warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [warehouse(1, "first")] })); + fixture.detectChanges(); + expect(component.warehouses.length).toBe(1); + + warehouseServiceSpy.getStatus.mockReturnValue(throwError(() => new Error("boom"))); + component.retry(); + fixture.detectChanges(); + + expect(component.warehouseEnabled).toBeUndefined(); + expect(component.warehouses).toEqual([]); + const notices = fixture.nativeElement.querySelectorAll(".warehouse-page-empty"); + expect(notices.length).toBe(1); + expect(notices[0].textContent).toContain("Could not load your warehouses"); + expect(fixture.debugElement.query(By.css(".create-btn")).nativeElement.disabled).toBe(true); + }); + + it("a late response from a superseded refresh cannot overwrite the newer state", () => { + const first = new Subject<{ enabled: boolean; warehouses: DashboardWarehouse[] }>(); + const second = new Subject<{ enabled: boolean; warehouses: DashboardWarehouse[] }>(); + warehouseServiceSpy.getStatus.mockReturnValueOnce(first.asObservable()).mockReturnValueOnce(second.asObservable()); + + fixture.detectChanges(); + component.retry(); + + second.next({ enabled: true, warehouses: [warehouse(2, "kept")] }); + second.complete(); + // The older request settles last; switchMap must already have dropped it. + first.next({ enabled: true, warehouses: [warehouse(1, "stale")] }); + first.complete(); + + expect(component.warehouses.map(w => w.name)).toEqual(["kept"]); + }); + + it("surfaces a failed status fetch as a notification", () => { + warehouseServiceSpy.getStatus.mockReturnValue(throwError(() => new Error("boom"))); + + fixture.detectChanges(); + + expect(notificationSpy.error).toHaveBeenCalledWith("Failed to fetch warehouses."); + // Still undefined, so the page reports the failure rather than claiming the + // feature is disabled. + expect(component.warehouseEnabled).toBeUndefined(); + expect(fixture.nativeElement.querySelector(".warehouse-page-empty")?.textContent).toContain( + "Could not load your warehouses" + ); + }); + + it("hands the warehouse to the actions service, and refreshes once it reports the delete", () => { + const actionsService = TestBed.inject(WarehouseActionsService); + const confirmAndDeleteSpy = vi.spyOn(actionsService, "confirmAndDelete").mockImplementation(() => {}); + fixture.detectChanges(); + warehouseServiceSpy.getStatus.mockClear(); + const doomed = warehouse(3, "doomed"); + + component.deleteWarehouse(doomed); + + expect(confirmAndDeleteSpy).toHaveBeenCalledTimes(1); + expect(confirmAndDeleteSpy.mock.calls[0][0]).toEqual(doomed); + const onDeleted = confirmAndDeleteSpy.mock.calls[0][1] as () => void; + onDeleted(); + expect(warehouseServiceSpy.getStatus).toHaveBeenCalledTimes(1); + }); + + it("refreshes the warehouse list when the modal emits warehouseCreated", () => { + fixture.detectChanges(); + warehouseServiceSpy.getStatus.mockClear(); + + const modal = fixture.debugElement.query(By.directive(WarehouseCreateModalComponent)).componentInstance; + modal.warehouseCreated.emit(warehouse(1, "mybucket")); + + expect(warehouseServiceSpy.getStatus).toHaveBeenCalledTimes(1); + }); + + it("syncs visibility when the embedded modal closes itself", () => { + fixture.detectChanges(); + component.showAddWarehouseModalVisible(); + expect(component.addWarehouseModalVisible).toBe(true); + + const modal = fixture.debugElement.query(By.directive(WarehouseCreateModalComponent)).componentInstance; + modal.visibleChange.emit(false); + + expect(component.addWarehouseModalVisible).toBe(false); + }); +}); diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.ts new file mode 100644 index 00000000000..847de6f3d7a --- /dev/null +++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.ts @@ -0,0 +1,142 @@ +/** + * 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, OnInit } from "@angular/core"; +import { NgIf } from "@angular/common"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { EMPTY, Subject, catchError, switchMap } from "rxjs"; + +import { ɵɵCdkVirtualScrollViewport, ɵɵCdkFixedSizeVirtualScroll, ɵɵCdkVirtualForOf } from "@angular/cdk/overlay"; +import { NzButtonComponent } from "ng-zorro-antd/button"; +import { NzCardComponent } from "ng-zorro-antd/card"; +import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch"; +import { NzWaveDirective } from "ng-zorro-antd/core/wave"; +import { NzIconDirective } from "ng-zorro-antd/icon"; +import { NzListComponent } from "ng-zorro-antd/list"; +import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space"; + +import { WarehouseCreateModalComponent } from "../../../../common/component/warehouse-create-modal/warehouse-create-modal.component"; +import { NotificationService } from "../../../../common/service/notification/notification.service"; +import { WarehouseActionsService } from "../../../../common/service/warehouse/warehouse-actions.service"; +import { WarehouseService } from "../../../../common/service/warehouse/warehouse.service"; +import { DashboardWarehouse } from "../../../../common/type/warehouse"; +import { UserWarehouseListItemComponent } from "./user-warehouse-list-item/user-warehouse-list-item.component"; + +/** + * Dashboard page for per-user warehouses (#6933), mirroring + * UserComputingUnitComponent: list the caller's warehouses, create one (Local + * flavor), delete one. Reachable only while the deployment reports the feature + * enabled; the page re-checks and says so otherwise. + */ +@UntilDestroy() +@Component({ + selector: "texera-user-warehouse", + templateUrl: "./user-warehouse.component.html", + styleUrls: ["./user-warehouse.component.scss"], + imports: [ + NgIf, + NzCardComponent, + NzSpaceCompactItemDirective, + NzButtonComponent, + NzWaveDirective, + ɵNzTransitionPatchDirective, + NzIconDirective, + ɵɵCdkVirtualScrollViewport, + ɵɵCdkFixedSizeVirtualScroll, + NzListComponent, + ɵɵCdkVirtualForOf, + UserWarehouseListItemComponent, + WarehouseCreateModalComponent, + ], +}) +export class UserWarehouseComponent implements OnInit { + // Undefined until the status request settles: with a plain false, a pending or + // failed request renders the "disabled in this deployment" notice, which names + // the wrong cause. + warehouseEnabled?: boolean; + warehouses: DashboardWarehouse[] = []; + // The status request failed: without this the page renders neither the + // disabled notice nor the list, leaving a blank card and no way back. + loadFailed = false; + + // visibility of the shared create-warehouse modal + addWarehouseModalVisible = false; + + constructor( + private warehouseService: WarehouseService, + private notificationService: NotificationService, + private warehouseActionsService: WarehouseActionsService + ) {} + + // All refreshes flow through one switchMap'd stream: a new request cancels + // the in-flight one, so a response arriving late can never overwrite newer + // state (say, resurrecting a warehouse a later refresh saw deleted). + private readonly refreshRequested$ = new Subject(); + + ngOnInit(): void { + this.refreshRequested$ + .pipe( + switchMap(() => + this.warehouseService.getStatus().pipe( + // Caught inside the switchMap so a failure ends only this request, + // not the stream — Retry must still work afterwards. + catchError((err: unknown) => { + this.loadFailed = true; + // A failed refresh must not leave the previous answer behind: + // stale rows (possibly including a just-deleted warehouse) and an + // enabled Create button would render alongside the failure + // notice, mixing the states this page promises to keep distinct. + this.warehouseEnabled = undefined; + this.warehouses = []; + console.error("Failed to fetch warehouses", err); + this.notificationService.error("Failed to fetch warehouses."); + return EMPTY; + }) + ) + ), + untilDestroyed(this) + ) + .subscribe(status => { + this.loadFailed = false; + this.warehouseEnabled = status.enabled; + this.warehouses = [...status.warehouses]; + }); + this.refresh(); + } + + retry(): void { + this.refresh(); + } + + private refresh(): void { + this.refreshRequested$.next(); + } + + deleteWarehouse(warehouse: DashboardWarehouse): void { + this.warehouseActionsService.confirmAndDelete(warehouse, () => this.refresh()); + } + + showAddWarehouseModalVisible(): void { + this.addWarehouseModalVisible = true; + } + + onWarehouseCreated(): void { + this.refresh(); + } +}