From 809bc843d43b857a2317e5d915da81e7c989133e Mon Sep 17 00:00:00 2001
From: mengw15 <125719918+mengw15@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:27:02 -0700
Subject: [PATCH 1/2] feat(frontend): add the warehouse dialogs and row card
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The three presentational pieces of the warehouse tab (#6933), each
mirroring its computing-unit counterpart: the create dialog, embedded
through [(visible)] like ComputingUnitCreateModalComponent — kept open on
failure, guarded against double submission, and abandoning its attempt
when closed so Cancel actually cancels; the read-only details dialog the
row name opens; and the row card itself, matched to the computing-unit
row down to its content height and hover-revealed delete.
Second of three parts, on top of the API client (#7820); the page that
renders them follows. Nothing reaches these components yet.
---
.../warehouse-create-modal.component.html | 59 +++++
.../warehouse-create-modal.component.scss | 48 ++++
.../warehouse-create-modal.component.spec.ts | 246 ++++++++++++++++++
.../warehouse-create-modal.component.ts | 117 +++++++++
.../warehouse-metadata.component.spec.ts | 70 +++++
.../warehouse-metadata.component.ts | 61 +++++
.../user-warehouse-list-item.component.html | 86 ++++++
.../user-warehouse-list-item.component.scss | 105 ++++++++
...user-warehouse-list-item.component.spec.ts | 98 +++++++
.../user-warehouse-list-item.component.ts | 81 ++++++
10 files changed, 971 insertions(+)
create mode 100644 frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.html
create mode 100644 frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.scss
create mode 100644 frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts
create mode 100644 frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts
create mode 100644 frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.spec.ts
create mode 100644 frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.ts
create mode 100644 frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.html
create mode 100644 frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss
create mode 100644 frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.spec.ts
create mode 100644 frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts
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..42724d56a70
--- /dev/null
+++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts
@@ -0,0 +1,246 @@
+/**
+ * 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("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..77d82e20b6b
--- /dev/null
+++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts
@@ -0,0 +1,117 @@
+/**
+ * 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;
+ }
+ }
+
+ 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: `
+
+
+
+
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();
+ }
+}