This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new dcf8217ff4 feat(frontend): add the warehouse dashboard tab (#8005)
dcf8217ff4 is described below

commit dcf8217ff4487fdbb648c33c4e3f8b159d819a51
Author: Meng Wang <[email protected]>
AuthorDate: Wed Sep 16 19:40:32 2026 +0000

    feat(frontend): add the warehouse dashboard tab (#8005)
    
    ### What changes were proposed in this PR?
    
    The warehouse dashboard tab (#6933), mirroring the computing-unit tab
    throughout. It sits on top of the merged API client (#7820) and ships
    the components and the page together, per review.
    
    Everything is gated behind the backend's warehouse feature flag
    (`storage.warehouse.enabled`): the sidebar entry reads it from the
    boot-time GUI config, the page re-checks it through `GET
    /warehouse/status`, and deployments with the flag off (the default) see
    no change at all.
    
    - **`user-warehouse` page + routing** — a "Warehouses" sidebar entry,
    shown only while the flag is on, plus the route. The route itself is
    unguarded on purpose: a deep link with the flag off lands on the page's
    disabled notice instead of silently redirecting, and the backend
    enforces the flag on every endpoint regardless. The page lists the
    user's warehouses in a virtual-scrolled list, opens the create dialog,
    appends on create, removes the row on delete, and distinguishes the
    three states the flag request can leave it in: enabled (the list),
    disabled (a notice that the deployment has the feature off), and failed
    (a retry button, instead of the misleading "disabled" notice).
    - **`warehouse-create-modal`** — embedded through `[(visible)]` and
    returning its result through `(warehouseCreated)`, the same contract as
    `ComputingUnitCreateModalComponent`. Following review, its submit flow
    mirrors the computing-unit dialog: Create fires the request and closes
    at once, with the outcome owned by the actions service — the toast and
    the list update arrive even if the user has navigated away mid-create.
    There is no in-flight dialog state to cancel, a late response never
    touches a dialog the user has since reopened, and a name that breaks the
    backend naming rule cannot be submitted at all.
    - **`warehouse-metadata`** — the read-only details dialog the row name
    opens: name, owner, flavor, created. The Lakekeeper catalog name is
    deliberately absent; it locates data inside storage the user has no
    access to until warehouses live in their own bucket (#6870, Phase 1).
    - **`user-warehouse-list-item`** — the row card, matched to the
    computing-unit row down to its 64px content height, hover-revealed
    delete, id column and metadata columns. Warehouses have no status,
    metrics, rename or sharing, so the computing-unit row's badge, progress
    bars, inline rename and share control have no counterpart.
    
    Screenshots and Videos:
    
    <img width="1493" height="785" alt="Screenshot 2026-08-27 at 1 06 01 PM"
    
src="https://github.com/user-attachments/assets/815feb50-7939-4a3c-94b2-9b02fe9a54ba";
    />
    
    <img width="1493" height="919" alt="Screenshot 2026-08-27 at 1 06 24 PM"
    
src="https://github.com/user-attachments/assets/a8c155fe-7fc7-48a0-a233-339603074497";
    />
    
    <img width="1515" height="811" alt="Screenshot 2026-08-27 at 1 06 40 PM"
    
src="https://github.com/user-attachments/assets/1f6af9c0-49f0-4527-88bf-7a85a59aea10";
    />
    
    
    
    
https://github.com/user-attachments/assets/44069306-0239-4692-bd31-0c3d1fb0d07b
    
    ### Any related issues, documentation, discussions?
    
    Closes #6933. Part of #6870; the on-canvas picker follows in #7817.
    
    ### How was this PR tested?
    
    - 67 Vitest tests across 7 spec files pass, covering the three
    components, the page, the dashboard sidebar gating, and the (already
    merged) services.
      - Whole suite as CI runs it: `yarn test:ci`
    - Just this change: `ng test --watch=false --include
    "src/app/common/component/warehouse-*/**/*.spec.ts" --include
    "src/app/dashboard/component/user/user-warehouse/**/*.spec.ts" --include
    "src/app/dashboard/component/dashboard.component.spec.ts"`
    - The dialogs are driven through their own controls — the Create and
    Cancel buttons, the Enter key, the text input — so the template bindings
    are verified too, not just component methods.
    - Failure paths verified rather than assumed: each behaviour was broken
    on purpose and the suite confirmed to fail for the expected reason
    before being restored.
    - Exercised end to end against a local deployment with the flag on,
    backed by live Lakekeeper and MinIO — screenshots and a video in the
    description above.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (claude-opus-5, claude-fable-5)
---
 .../texera/service/resource/ConfigResource.scala   |   2 +
 frontend/src/app/app-routing.constant.ts           |   1 +
 frontend/src/app/app-routing.module.ts             |   5 +
 .../warehouse-create-modal.component.html          |  57 ++++
 .../warehouse-create-modal.component.scss          |  48 ++++
 .../warehouse-create-modal.component.spec.ts       | 238 +++++++++++++++++
 .../warehouse-create-modal.component.ts            | 113 ++++++++
 .../warehouse-metadata.component.spec.ts           |  70 +++++
 .../warehouse-metadata.component.ts                |  61 +++++
 .../app/common/service/gui-config.service.mock.ts  |   1 +
 .../warehouse/warehouse-actions.service.spec.ts    |  65 ++++-
 .../service/warehouse/warehouse-actions.service.ts |  22 +-
 frontend/src/app/common/type/gui-config.ts         |   1 +
 .../dashboard/component/dashboard.component.html   |  12 +
 .../component/dashboard.component.spec.ts          |  25 ++
 .../app/dashboard/component/dashboard.component.ts |   2 +
 .../src/app/dashboard/component/section-style.scss |  32 +++
 .../user-computing-unit-list-item.component.scss   |  30 ---
 .../user-warehouse-list-item.component.html        |  86 ++++++
 .../user-warehouse-list-item.component.scss        |  77 ++++++
 .../user-warehouse-list-item.component.spec.ts     |  92 +++++++
 .../user-warehouse-list-item.component.ts          |  73 ++++++
 .../user-warehouse/user-warehouse.component.html   |  84 ++++++
 .../user-warehouse/user-warehouse.component.scss   |  29 +++
 .../user-warehouse.component.spec.ts               | 290 +++++++++++++++++++++
 .../user-warehouse/user-warehouse.component.ts     | 150 +++++++++++
 26 files changed, 1633 insertions(+), 33 deletions(-)

diff --git 
a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
 
b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
index 5bdba890c2..d682fe8d02 100644
--- 
a/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
+++ 
b/config-service/src/main/scala/org/apache/texera/service/resource/ConfigResource.scala
@@ -30,6 +30,7 @@ import org.apache.texera.common.config.{
   ComputingUnitConfig,
   DefaultsConfig,
   GuiConfig,
+  StorageConfig,
   UserSystemConfig
 }
 import org.apache.texera.dao.{SiteSettings, SqlServer}
@@ -95,6 +96,7 @@ class ConfigResource {
       "defaultExecutionMode" -> 
GuiConfig.guiWorkflowWorkspaceDefaultExecutionMode,
       "workflowEmailNotificationEnabled" -> 
GuiConfig.guiWorkflowWorkspaceWorkflowEmailNotificationEnabled,
       "sharingComputingUnitEnabled" -> 
ComputingUnitConfig.sharingComputingUnitEnabled,
+      "warehouseEnabled" -> StorageConfig.warehouseEnabled,
       "operatorConsoleMessageBufferSize" -> 
GuiConfig.guiWorkflowWorkspaceOperatorConsoleMessageBufferSize,
       "pythonLanguageServerPort" -> 
GuiConfig.guiWorkflowWorkspacePythonLanguageServerPort,
       "activeTimeInMinutes" -> 
GuiConfig.guiWorkflowWorkspaceActiveTimeInMinutes,
diff --git a/frontend/src/app/app-routing.constant.ts 
b/frontend/src/app/app-routing.constant.ts
index 1855d1cb45..0c10a07fb4 100644
--- a/frontend/src/app/app-routing.constant.ts
+++ b/frontend/src/app/app-routing.constant.ts
@@ -39,6 +39,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 d076c7e88c..05c96925aa 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 { WorkflowFormComponent } from 
"./workspace/component/workflow-form/workflow-form.component";
@@ -170,6 +171,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 0000000000..0ac81ac2d7
--- /dev/null
+++ 
b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.html
@@ -0,0 +1,57 @@
+<!--
+ 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.
+-->
+
+<nz-modal
+  [nzVisible]="visible"
+  nzTitle="Create Warehouse"
+  [nzContent]="createWarehouseModalContent"
+  [nzFooter]="createWarehouseModalFooter"
+  (nzOnCancel)="handleCreateWarehouseModalCancel()">
+  <ng-template #createWarehouseModalContent>
+    <div class="create-warehouse-container">
+      <div class="select-unit name-field">
+        <span>Warehouse Name</span>
+        <input
+          nz-input
+          placeholder="Enter the name of your warehouse (required)"
+          maxlength="64"
+          [(ngModel)]="newWarehouseName"
+          class="warehouse-name-input"
+          (keyup.enter)="handleCreateWarehouseModalOk()" />
+        <p class="warehouse-name-hint">Letters, digits, '-' and '_' only; must 
start with a letter or digit.</p>
+      </div>
+    </div>
+  </ng-template>
+  <ng-template #createWarehouseModalFooter>
+    <button
+      nz-button
+      nzType="default"
+      (click)="handleCreateWarehouseModalCancel()">
+      Cancel
+    </button>
+    <button
+      nz-button
+      nzType="primary"
+      id="confirm-create-warehouse-btn"
+      [disabled]="!isValidWarehouseName()"
+      (click)="handleCreateWarehouseModalOk()">
+      Create
+    </button>
+  </ng-template>
+</nz-modal>
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 0000000000..4f891f64c2
--- /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 0000000000..e5d262fff9
--- /dev/null
+++ 
b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts
@@ -0,0 +1,238 @@
+/**
+ * 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 } from "rxjs";
+import { WarehouseCreateModalComponent } from 
"./warehouse-create-modal.component";
+import { WarehouseActionsService } from 
"../../service/warehouse/warehouse-actions.service";
+import { DashboardWarehouse } from "../../type/warehouse";
+import { commonTestProviders } from "../../testing/test-utils";
+
+describe("WarehouseCreateModalComponent", () => {
+  let fixture: ComponentFixture<WarehouseCreateModalComponent>;
+  let component: WarehouseCreateModalComponent;
+  let warehouseActions: { create: ReturnType<typeof vi.fn> };
+
+  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)) };
+
+    await TestBed.configureTestingModule({
+      imports: [WarehouseCreateModalComponent, NoopAnimationsModule, 
HttpClientTestingModule],
+      providers: [
+        // The rendered <nz-modal> injects NzModalService itself.
+        NzModalService,
+        { provide: WarehouseActionsService, useValue: warehouseActions },
+        ...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("disables Create until the name matches the backend's naming rule", () => 
{
+    component.visible = true;
+    fixture.detectChanges();
+
+    const createButton = 
document.querySelector<HTMLButtonElement>("#confirm-create-warehouse-btn")!;
+    expect(createButton.disabled).toBe(true);
+
+    for (const invalid of ["my bucket", "-starts-wrong", "no/slash"]) {
+      component.newWarehouseName = invalid;
+      fixture.detectChanges();
+      expect(createButton.disabled).toBe(true);
+    }
+
+    component.newWarehouseName = "my-bucket_1";
+    fixture.detectChanges();
+    expect(createButton.disabled).toBe(false);
+  });
+
+  it("takes what the user types through the two-way binding", async () => {
+    component.visible = true;
+    fixture.detectChanges();
+
+    const input = document.querySelector<HTMLInputElement>("input[nz-input]")!;
+    input.value = "typed-in";
+    input.dispatchEvent(new Event("input", { bubbles: true }));
+    await fixture.whenStable();
+
+    expect(component.newWarehouseName).toBe("typed-in");
+  });
+
+  it("create fires the trimmed request, clears the name, and closes at once", 
() => {
+    warehouseActions.create.mockReturnValue(new 
Subject<DashboardWarehouse>().asObservable());
+    const visibleSpy = vi.fn();
+    component.visibleChange.subscribe(visibleSpy);
+    component.visible = true;
+    component.newWarehouseName = "  mybucket  ";
+    fixture.detectChanges();
+
+    
document.querySelector<HTMLButtonElement>("#confirm-create-warehouse-btn")!.click();
+
+    expect(warehouseActions.create).toHaveBeenCalledWith("mybucket");
+    expect(component.newWarehouseName).toBe("");
+    expect(component.visible).toBe(false);
+    expect(visibleSpy).toHaveBeenCalledWith(false);
+  });
+
+  it("a second rapid click cannot fire a duplicate create", () => {
+    // The dialog stays clickable through its close animation.
+    warehouseActions.create.mockReturnValue(new 
Subject<DashboardWarehouse>().asObservable());
+    component.visible = true;
+    component.newWarehouseName = "mybucket";
+    fixture.detectChanges();
+
+    const createButton = 
document.querySelector<HTMLButtonElement>("#confirm-create-warehouse-btn")!;
+    createButton.click();
+    createButton.click();
+
+    expect(warehouseActions.create).toHaveBeenCalledTimes(1);
+  });
+
+  it("the Enter key submits and closes the same way", () => {
+    warehouseActions.create.mockReturnValue(new 
Subject<DashboardWarehouse>().asObservable());
+    component.visible = true;
+    component.newWarehouseName = "again";
+    fixture.detectChanges();
+
+    document
+      .querySelector<HTMLInputElement>("input[nz-input]")!
+      .dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true 
}));
+
+    expect(warehouseActions.create).toHaveBeenCalledWith("again");
+    expect(component.visible).toBe(false);
+  });
+
+  it("Enter on an invalid name does nothing and keeps the dialog open", () => {
+    component.visible = true;
+    fixture.detectChanges();
+
+    for (const invalid of ["   ", "bad name"]) {
+      component.newWarehouseName = invalid;
+      document
+        .querySelector<HTMLInputElement>("input[nz-input]")!
+        .dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: 
true }));
+    }
+
+    expect(warehouseActions.create).not.toHaveBeenCalled();
+    expect(component.visible).toBe(true);
+  });
+
+  it("relays a create that lands after the close without touching a reopened 
dialog", () => {
+    // The toasts belong to the actions service; what the dialog owes its host
+    // is the (warehouseCreated) relay — and hands off the dialog the user has
+    // since reopened mid-typing.
+    const inFlight = new Subject<DashboardWarehouse>();
+    warehouseActions.create.mockReturnValue(inFlight.asObservable());
+    const createdSpy = vi.fn();
+    component.warehouseCreated.subscribe(createdSpy);
+    component.visible = true;
+    component.newWarehouseName = "first";
+    component.handleCreateWarehouseModalOk();
+
+    component.visible = true;
+    component.newWarehouseName = "second-in-progress";
+    inFlight.next(created);
+    inFlight.complete();
+
+    expect(createdSpy).toHaveBeenCalledWith(created);
+    expect(component.visible).toBe(true);
+    expect(component.newWarehouseName).toBe("second-in-progress");
+  });
+
+  it("a create that fails after the close never surfaces as an unhandled RxJS 
error", () => {
+    // RxJS reports an error hitting a subscriber without an error path via a
+    // thrown setTimeout; flushing fake timers makes that deterministic.
+    vi.useFakeTimers();
+    try {
+      const inFlight = new Subject<DashboardWarehouse>();
+      warehouseActions.create.mockReturnValue(inFlight.asObservable());
+      const createdSpy = vi.fn();
+      component.warehouseCreated.subscribe(createdSpy);
+      component.visible = true;
+      component.newWarehouseName = "first";
+      component.handleCreateWarehouseModalOk();
+
+      inFlight.error({ error: "boom" });
+
+      expect(() => vi.runAllTimers()).not.toThrow();
+      expect(createdSpy).not.toHaveBeenCalled();
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
+  it("clears the previous name when the modal opens", () => {
+    component.newWarehouseName = "leftover";
+    component.visible = true;
+
+    component.ngOnChanges({ visible: new SimpleChange(false, true, false) });
+
+    expect(component.newWarehouseName).toBe("");
+  });
+
+  it("leaves the form alone when a change does not open the dialog", () => {
+    component.newWarehouseName = "typing";
+
+    component.ngOnChanges({});
+
+    expect(component.newWarehouseName).toBe("typing");
+  });
+
+  it("cancel closes without creating", () => {
+    const visibleSpy = vi.fn();
+    component.visibleChange.subscribe(visibleSpy);
+    component.visible = true;
+    fixture.detectChanges();
+
+    const cancel = 
Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(
+      b => b.textContent?.trim() === "Cancel"
+    )!;
+    cancel.click();
+
+    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 0000000000..b5c21a024f
--- /dev/null
+++ 
b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts
@@ -0,0 +1,113 @@
+/**
+ * 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 { 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 { WarehouseActionsService } from 
"../../service/warehouse/warehouse-actions.service";
+import { DashboardWarehouse } from "../../type/warehouse";
+
+/**
+ * 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<boolean>();
+  @Output() warehouseCreated = new EventEmitter<DashboardWarehouse>();
+
+  newWarehouseName = "";
+
+  constructor(private warehouseActionsService: WarehouseActionsService) {}
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes["visible"]?.currentValue === true) {
+      this.newWarehouseName = "";
+    }
+  }
+
+  // Mirrors the backend's VFSURIFactory.warehouseNamePattern (≤64 comes from
+  // the input's maxlength), so an invalid name never leaves the dialog: the
+  // Create button stays disabled and the Enter path returns early.
+  private static readonly VALID_WAREHOUSE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
+
+  isValidWarehouseName(): boolean {
+    return 
WarehouseCreateModalComponent.VALID_WAREHOUSE_NAME.test(this.newWarehouseName.trim());
+  }
+
+  /**
+   * Mirrors ComputingUnitCreateModalComponent's submit flow: Create fires the
+   * request and closes the dialog at once. The actions service owns the 
request
+   * and its toasts, so the outcome arrives even if the user has navigated 
away;
+   * this dialog only relays the created warehouse to its host while it is
+   * still alive.
+   */
+  handleCreateWarehouseModalOk(): void {
+    if (!this.isValidWarehouseName()) {
+      return;
+    }
+    const name = this.newWarehouseName.trim();
+    // The dialog stays clickable through its close animation; clearing the
+    // name drops a second rapid click into the guard above instead of firing
+    // a duplicate create.
+    this.newWarehouseName = "";
+    this.warehouseActionsService
+      .create(name)
+      .pipe(untilDestroyed(this))
+      .subscribe({
+        next: created => this.warehouseCreated.emit(created),
+        // The service reports failures and returns a stream that completes
+        // empty; this guard only keeps a future contract change from
+        // surfacing as an unhandled RxJS error.
+        error: () => {},
+      });
+    this.closeModal();
+  }
+
+  handleCreateWarehouseModalCancel(): void {
+    this.closeModal();
+  }
+
+  private closeModal(): void {
+    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 0000000000..dbb294b71e
--- /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 0000000000..83d9abcce5
--- /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: `
+    <table class="ant-table">
+      <tbody>
+        <tr>
+          <th style="width: 150px;">Name</th>
+          <td>{{ warehouse.name }}</td>
+        </tr>
+        <tr>
+          <th>Owner</th>
+          <td>{{ warehouse.ownerName || "None" }}</td>
+        </tr>
+        <tr>
+          <th>Flavor</th>
+          <td>{{ warehouse.flavor }}</td>
+        </tr>
+        <tr>
+          <th>Created</th>
+          <td>{{ createdAt }}</td>
+        </tr>
+      </tbody>
+    </table>
+  `,
+})
+export class WarehouseMetadataComponent {
+  readonly warehouse: DashboardWarehouse = inject(NZ_MODAL_DATA);
+  readonly createdAt = new 
Date(this.warehouse.createdAtMillis).toLocaleString();
+}
diff --git a/frontend/src/app/common/service/gui-config.service.mock.ts 
b/frontend/src/app/common/service/gui-config.service.mock.ts
index c4441540cb..8591fc727a 100644
--- a/frontend/src/app/common/service/gui-config.service.mock.ts
+++ b/frontend/src/app/common/service/gui-config.service.mock.ts
@@ -49,6 +49,7 @@ export class MockGuiConfigService {
     defaultExecutionMode: ExecutionMode.PIPELINED,
     workflowEmailNotificationEnabled: false,
     sharingComputingUnitEnabled: false,
+    warehouseEnabled: false,
     operatorConsoleMessageBufferSize: 1000,
     defaultLocalUser: { username: "", password: "" },
     activeTimeInMinutes: 15,
diff --git 
a/frontend/src/app/common/service/warehouse/warehouse-actions.service.spec.ts 
b/frontend/src/app/common/service/warehouse/warehouse-actions.service.spec.ts
index d56a9751ea..daf654ffae 100644
--- 
a/frontend/src/app/common/service/warehouse/warehouse-actions.service.spec.ts
+++ 
b/frontend/src/app/common/service/warehouse/warehouse-actions.service.spec.ts
@@ -18,7 +18,7 @@
  */
 
 import { TestBed } from "@angular/core/testing";
-import { Subject, firstValueFrom, of, throwError } from "rxjs";
+import { Observable, Subject, firstValueFrom, of, throwError } from "rxjs";
 import { NzModalService } from "ng-zorro-antd/modal";
 import { WarehouseActionsService } from "./warehouse-actions.service";
 import { WarehouseService } from "./warehouse.service";
@@ -65,6 +65,69 @@ describe("WarehouseActionsService", () => {
     service = TestBed.inject(WarehouseActionsService);
   });
 
+  describe("create", () => {
+    it("toasts the success and hands the created warehouse to the caller", 
async () => {
+      const seen = firstValueFrom(service.create("sales"));
+
+      expect(warehouseService.createWarehouse).toHaveBeenCalledWith("sales");
+      expect(notificationService.success).toHaveBeenCalledWith('Warehouse 
"sales" created.');
+      expect(await seen).toEqual(warehouse);
+    });
+
+    it("toasts the backend message when the create fails", () => {
+      warehouseService.createWarehouse.mockReturnValue(throwError(() => ({ 
error: "'sales' already exists" })));
+
+      service.create("sales").subscribe({ error: () => {} });
+
+      expect(notificationService.error).toHaveBeenCalledWith("Failed to create 
warehouse: 'sales' already exists");
+    });
+
+    it("a failed create completes the caller's stream empty instead of 
erroring it", () => {
+      // A replayed error would land in relays that have no error path and
+      // surface as an unhandled RxJS error; the failure was already toasted.
+      warehouseService.createWarehouse.mockReturnValue(throwError(() => ({ 
error: "boom" })));
+      const next = vi.fn();
+      const error = vi.fn();
+      const complete = vi.fn();
+
+      service.create("sales").subscribe({ next, error, complete });
+
+      expect(error).not.toHaveBeenCalled();
+      expect(next).not.toHaveBeenCalled();
+      expect(complete).toHaveBeenCalled();
+      expect(notificationService.error).toHaveBeenCalledWith("Failed to create 
warehouse: boom");
+    });
+
+    it("the request and its toast outlive the caller's subscription", () => {
+      // The dialog (or its whole page) can be destroyed mid-create; the
+      // service still owns the request, so nothing is aborted or swallowed.
+      const inFlight = new Subject<DashboardWarehouse>();
+      
warehouseService.createWarehouse.mockReturnValue(inFlight.asObservable());
+
+      const callerSub = service.create("sales").subscribe();
+      callerSub.unsubscribe();
+      inFlight.next(warehouse);
+      inFlight.complete();
+
+      expect(notificationService.success).toHaveBeenCalledWith('Warehouse 
"sales" created.');
+    });
+
+    it("issues exactly one request even with the service and the caller both 
subscribed", () => {
+      // HttpClient observables are cold — every subscription is its own HTTP
+      // request — so count subscriptions to the source, not mock calls.
+      let subscriptions = 0;
+      warehouseService.createWarehouse.mockReturnValue(
+        new Observable<DashboardWarehouse>(() => {
+          subscriptions++;
+        })
+      );
+
+      service.create("sales").subscribe();
+
+      expect(subscriptions).toBe(1);
+    });
+  });
+
   it("creates through the warehouse service, so create and delete share one 
entry point", async () => {
     const created = await firstValueFrom(service.create("mybucket"));
 
diff --git 
a/frontend/src/app/common/service/warehouse/warehouse-actions.service.ts 
b/frontend/src/app/common/service/warehouse/warehouse-actions.service.ts
index 4077719d6c..bc8e06545e 100644
--- a/frontend/src/app/common/service/warehouse/warehouse-actions.service.ts
+++ b/frontend/src/app/common/service/warehouse/warehouse-actions.service.ts
@@ -19,6 +19,7 @@
 
 import { Injectable } from "@angular/core";
 import { NzModalService } from "ng-zorro-antd/modal";
+import { EMPTY, catchError, shareReplay } from "rxjs";
 import { Observable, firstValueFrom } from "rxjs";
 import { NotificationService } from "../notification/notification.service";
 import { DashboardWarehouse } from "../../type/warehouse";
@@ -41,9 +42,26 @@ export class WarehouseActionsService {
     private notificationService: NotificationService
   ) {}
 
-  /** Creates a warehouse. Needs no confirmation, unlike the delete below. */
+  /**
+   * Creates a warehouse and reports the outcome itself: the service owns the
+   * subscription, so the request and its toasts survive the dialog — and the
+   * whole page — being destroyed. Without this, navigating away mid-create
+   * aborts the browser request while the server finishes anyway: the warehouse
+   * exists, nothing was reported, and the next same-name attempt fails
+   * confusingly. The returned observable replays the created warehouse for
+   * callers that want it (the dialog relays it to its host) and never errors:
+   * a failure is already reported here, and replaying it would surface in
+   * relays with no error path as an unhandled RxJS error — the stream just
+   * completes empty instead.
+   */
   create(name: string): Observable<DashboardWarehouse> {
-    return this.warehouseService.createWarehouse(name);
+    const request$ = 
this.warehouseService.createWarehouse(name).pipe(shareReplay({ bufferSize: 1, 
refCount: false }));
+    request$.subscribe({
+      next: created => this.notificationService.success(`Warehouse 
"${created.name}" created.`),
+      error: (err: unknown) =>
+        this.notificationService.error(`Failed to create warehouse: 
${extractErrorMessage(err)}`),
+    });
+    return request$.pipe(catchError(() => EMPTY));
   }
 
   /**
diff --git a/frontend/src/app/common/type/gui-config.ts 
b/frontend/src/app/common/type/gui-config.ts
index 27dff5d084..5b8b23db03 100644
--- a/frontend/src/app/common/type/gui-config.ts
+++ b/frontend/src/app/common/type/gui-config.ts
@@ -40,6 +40,7 @@ export interface GuiConfig {
   defaultExecutionMode: ExecutionMode;
   workflowEmailNotificationEnabled: boolean;
   sharingComputingUnitEnabled: boolean;
+  warehouseEnabled: boolean;
   operatorConsoleMessageBufferSize: number;
   defaultLocalUser?: { username?: string; password?: string };
   activeTimeInMinutes: number;
diff --git a/frontend/src/app/dashboard/component/dashboard.component.html 
b/frontend/src/app/dashboard/component/dashboard.component.html
index e0c790ca56..0b1c7a1d56 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"></span>
             <span>Compute</span>
           </li>
+          <li
+            *ngIf="config.env.warehouseEnabled"
+            nz-menu-item
+            nz-tooltip="Manage warehouses"
+            nzMatchRouter="true"
+            nzTooltipPlacement="right"
+            [routerLink]="USER_WAREHOUSE">
+            <span
+              nz-icon
+              nzType="cloud-server"></span>
+            <span>Warehouses</span>
+          </li>
           <li
             nz-menu-item
             nz-tooltip="Manage saved Python virtual environments"
diff --git a/frontend/src/app/dashboard/component/dashboard.component.spec.ts 
b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
index 84795e0db1..87ebe64b62 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.spec.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.spec.ts
@@ -52,6 +52,7 @@ import {
   USER_DATASET,
   USER_DISCUSSION,
   USER_QUOTA,
+  USER_WAREHOUSE,
   USER_WORKFLOW,
 } from "../../app-routing.constant";
 
@@ -262,6 +263,7 @@ describe("DashboardComponent", () => {
     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");
@@ -297,6 +299,29 @@ 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 deployment's config enables 
the feature", () => {
+      TestBed.inject(GuiConfigService).env.warehouseEnabled = false;
+      fixture.detectChanges();
+      expect(warehouseMenuItem()).toBeUndefined();
+
+      TestBed.inject(GuiConfigService).env.warehouseEnabled = true;
+      fixture.detectChanges();
+      expect(warehouseMenuItem()).toBeTruthy();
+    });
+  });
+
   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 e4fd8bad7b..98d0238514 100644
--- a/frontend/src/app/dashboard/component/dashboard.component.ts
+++ b/frontend/src/app/dashboard/component/dashboard.component.ts
@@ -39,6 +39,7 @@ import {
   USER_DISCUSSION,
   USER_PYTHON_VENV,
   USER_QUOTA,
+  USER_WAREHOUSE,
   USER_WORKFLOW,
   USER_FEEDBACK,
   LOGIN,
@@ -120,6 +121,7 @@ export class DashboardComponent implements OnInit {
   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;
diff --git a/frontend/src/app/dashboard/component/section-style.scss 
b/frontend/src/app/dashboard/component/section-style.scss
index aee195eeca..784815fb4a 100644
--- a/frontend/src/app/dashboard/component/section-style.scss
+++ b/frontend/src/app/dashboard/component/section-style.scss
@@ -198,6 +198,38 @@ $dashboard-navigation-height: 76px;
   }
 }
 
+// Shared look of one resource row (the computing-unit and warehouse list
+// items): icon, name, trailing metadata columns.
+.type-icon {
+  font-size: 30px;
+}
+
+.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;
+}
+
 .subsection-grid-container {
   min-width: 100%;
   width: 100%;
diff --git 
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.scss
 
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.scss
index eb4ef6c8a9..0229199ac6 100644
--- 
a/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.scss
+++ 
b/frontend/src/app/dashboard/component/user/user-computing-unit/user-computing-unit-list-item/user-computing-unit-list-item.component.scss
@@ -59,40 +59,10 @@
   }
 }
 
-.type-icon {
-  font-size: 30px;
-}
-
 .unit-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;
-}
-
 .unit-name-edit-input {
   width: 100%;
   max-width: 200px;
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 0000000000..9db53eee96
--- /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 @@
+<!--
+ 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.
+-->
+
+<nz-card
+  [nzBodyStyle]="{padding: '3px'}"
+  class="warehouse-list-item-card">
+  <div
+    nz-row
+    nzAlign="middle"
+    class="warehouse-item-row">
+    <div
+      nz-col
+      nzFlex="20px"></div>
+
+    <div
+      nz-col
+      nzFlex="0"
+      class="type-icon">
+      <i
+        nz-icon
+        nzType="cloud-server"></i>
+    </div>
+
+    <div
+      nz-col
+      nzFlex="0"
+      class="warehouse-id">
+      <i>#{{ warehouse.whid }}</i>
+    </div>
+
+    <div
+      nz-col
+      nzFlex="1"
+      class="resource-name-group">
+      <div
+        class="resource-name truncate-single-line"
+        (click)="openWarehouseMetadataModal()">
+        {{ warehouse.name }}
+      </div>
+    </div>
+
+    <div class="button-group">
+      <button
+        nz-button
+        nzType="text"
+        title="Delete"
+        (click)="deleted.emit()">
+        <i
+          nz-icon
+          nzType="delete"></i>
+      </button>
+    </div>
+
+    <div
+      nz-col
+      nzFlex="100px"
+      class="resource-info">
+      Created:<br />
+      {{ createdRelative }}
+    </div>
+
+    <div
+      nz-col
+      nzFlex="90px"
+      class="resource-info">
+      Flavor:<br />
+      {{ warehouse.flavor }}
+    </div>
+  </div>
+</nz-card>
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 0000000000..0bf8467997
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss
@@ -0,0 +1,77 @@
+/**
+ * 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 *;
+
+.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;
+}
+
+// Shown on hover, and while anything in the row has the keyboard focus, the
+// same as the generic list-item card.
+.warehouse-list-item-card:hover .button-group,
+.warehouse-list-item-card:focus-within .button-group {
+  display: flex;
+  background-color: transparent;
+}
+
+.warehouse-id {
+  padding: 6px;
+}
+
+.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 0000000000..f2289b918a
--- /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,92 @@
+/**
+ * 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<UserWarehouseListItemComponent>;
+
+  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("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 0000000000..57ae45e78e
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts
@@ -0,0 +1,73 @@
+/**
+ * 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 } 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";
+
+/**
+ * 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,
+    NzButtonComponent,
+  ],
+})
+export class UserWarehouseListItemComponent implements OnChanges {
+  @Input({ required: true }) warehouse!: DashboardWarehouse;
+  @Output() deleted = new EventEmitter<void>();
+
+  // Computed when the input changes rather than in the template, which would
+  // re-run the formatting on every change-detection pass of every row.
+  createdRelative = "";
+
+  constructor(private modalService: NzModalService) {}
+
+  ngOnChanges(): void {
+    this.createdRelative = formatRelativeTime(this.warehouse.createdAtMillis);
+  }
+
+  openWarehouseMetadataModal(): void {
+    this.modalService.create({
+      nzTitle: "Warehouse Information",
+      nzContent: WarehouseMetadataComponent,
+      nzData: this.warehouse,
+      nzFooter: null,
+      nzMaskClosable: true,
+      nzWidth: "600px",
+    });
+  }
+}
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 0000000000..1146598e1e
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.html
@@ -0,0 +1,84 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+<div class="section-container subsection-grid-container">
+  <nz-card class="section-title">
+    <h2 class="page-title">Warehouses</h2>
+    <div class="button-group">
+      <button
+        nz-button
+        class="create-btn"
+        (click)="showAddWarehouseModalVisible()"
+        [disabled]="!warehouseEnabled"
+        title="Create Warehouse">
+        <i
+          nz-icon
+          nzType="file-add"
+          nzTheme="outline"></i>
+        <span>Create Warehouse</span>
+      </button>
+    </div>
+  </nz-card>
+
+  <nz-card
+    class="section-list-container"
+    [nzBodyStyle]="{ height: '100%'}">
+    <div
+      *ngIf="loadFailed"
+      class="warehouse-page-empty">
+      Could not load your warehouses.
+      <button
+        nz-button
+        nzType="link"
+        (click)="retry()">
+        Retry
+      </button>
+    </div>
+
+    <div
+      *ngIf="warehouseEnabled === false"
+      class="warehouse-page-empty">
+      Per-user warehouses are disabled in this deployment.
+    </div>
+
+    <div
+      *ngIf="warehouseEnabled && warehouses.length === 0"
+      class="warehouse-page-empty">
+      No warehouses yet. Click <strong>Create Warehouse</strong> to create one.
+    </div>
+
+    <cdk-virtual-scroll-viewport
+      *ngIf="warehouseEnabled && warehouses.length > 0"
+      itemSize="78"
+      class="virtual-scroll-container">
+      <nz-list>
+        <ng-container *cdkVirtualFor="let warehouse of warehouses; trackBy: 
trackByWarehouse">
+          <texera-user-warehouse-list-item
+            (deleted)="deleteWarehouse(warehouse)"
+            [warehouse]="warehouse"></texera-user-warehouse-list-item>
+        </ng-container>
+      </nz-list>
+    </cdk-virtual-scroll-viewport>
+  </nz-card>
+</div>
+
+<!-- Panel for creating the warehouse -->
+<texera-warehouse-create-modal
+  [(visible)]="addWarehouseModalVisible"
+  
(warehouseCreated)="onWarehouseCreated($event)"></texera-warehouse-create-modal>
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 0000000000..1fb9ed6be0
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.scss
@@ -0,0 +1,29 @@
+/**
+ * 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 "../../button-style" as *;
+
+.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 0000000000..e303264055
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.spec.ts
@@ -0,0 +1,290 @@
+/**
+ * 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<UserWarehouseComponent>;
+
+  let warehouseServiceSpy: {
+    getStatus: ReturnType<typeof vi.fn>;
+    createWarehouse: ReturnType<typeof vi.fn>;
+    deleteWarehouse: ReturnType<typeof vi.fn>;
+  };
+  let notificationSpy: {
+    error: ReturnType<typeof vi.fn>;
+    success: ReturnType<typeof vi.fn>;
+    info: ReturnType<typeof vi.fn>;
+    warning: ReturnType<typeof vi.fn>;
+  };
+  let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
+
+  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("tracks virtual-scroll rows by warehouse id", () => {
+    expect(component.trackByWarehouse(0, warehouse(5, "any"))).toBe(5);
+  });
+
+  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: boom");
+    // 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 removes the row without 
refetching", () => {
+    warehouseServiceSpy.getStatus.mockReturnValue(
+      of({ enabled: true, warehouses: [warehouse(3, "doomed"), warehouse(4, 
"kept")] })
+    );
+    const actionsService = TestBed.inject(WarehouseActionsService);
+    const confirmAndDeleteSpy = vi.spyOn(actionsService, 
"confirmAndDelete").mockImplementation(() => {});
+    fixture.detectChanges();
+    warehouseServiceSpy.getStatus.mockClear();
+
+    component.deleteWarehouse(component.warehouses[0]);
+    expect(confirmAndDeleteSpy).toHaveBeenCalledTimes(1);
+    const onDeleted = confirmAndDeleteSpy.mock.calls[0][1] as () => void;
+    onDeleted();
+
+    expect(component.warehouses.map(w => w.whid)).toEqual([4]);
+    expect(warehouseServiceSpy.getStatus).not.toHaveBeenCalled();
+  });
+
+  it("appends the created warehouse instead of refetching the list", () => {
+    fixture.detectChanges();
+    warehouseServiceSpy.getStatus.mockClear();
+
+    const modal = 
fixture.debugElement.query(By.directive(WarehouseCreateModalComponent)).componentInstance;
+    modal.warehouseCreated.emit(warehouse(9, "fresh"));
+
+    // The backend orders by created_at ascending, so the append keeps order.
+    expect(component.warehouses.map(w => w.whid)).toEqual([9]);
+    expect(warehouseServiceSpy.getStatus).not.toHaveBeenCalled();
+  });
+
+  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 0000000000..297ed6c663
--- /dev/null
+++ 
b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.ts
@@ -0,0 +1,150 @@
+/**
+ * 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 { 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 { extractErrorMessage } from "../../../../common/util/error";
+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,
+    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<void>();
+
+  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: 
${extractErrorMessage(err)}`);
+              return EMPTY;
+            })
+          )
+        ),
+        untilDestroyed(this)
+      )
+      .subscribe(status => {
+        this.loadFailed = false;
+        this.warehouseEnabled = status.enabled;
+        this.warehouses = [...status.warehouses];
+      });
+    this.refresh();
+  }
+
+  retry(): void {
+    this.refresh();
+  }
+
+  // Identity for *cdkVirtualFor, so a refresh reuses the rendered rows instead
+  // of rebuilding every one.
+  trackByWarehouse = (_: number, warehouse: DashboardWarehouse): number => 
warehouse.whid;
+
+  private refresh(): void {
+    this.refreshRequested$.next();
+  }
+
+  deleteWarehouse(warehouse: DashboardWarehouse): void {
+    this.warehouseActionsService.confirmAndDelete(warehouse, () => {
+      // Same reasoning as the append on create: no round trip needed.
+      this.warehouses = this.warehouses.filter(w => w.whid !== warehouse.whid);
+    });
+  }
+
+  showAddWarehouseModalVisible(): void {
+    this.addWarehouseModalVisible = true;
+  }
+
+  onWarehouseCreated(created: DashboardWarehouse): void {
+    // The backend lists warehouses by created_at ascending, so appending keeps
+    // the order without another round trip.
+    this.warehouses = [...this.warehouses, created];
+  }
+}

Reply via email to