Letters, digits, '-' and '_' only; must start with a letter or digit.
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.scss b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.scss
new file mode 100644
index 00000000000..4f891f64c27
--- /dev/null
+++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.scss
@@ -0,0 +1,48 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.create-warehouse-container {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+ justify-content: start;
+ align-items: center;
+}
+
+.select-unit {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ justify-content: center;
+ align-items: flex-start;
+}
+
+.select-unit.name-field {
+ grid-column: span 2;
+}
+
+.warehouse-name-input {
+ width: 100%;
+}
+
+.warehouse-name-hint {
+ margin: 8px 0 0;
+ color: rgba(0, 0, 0, 0.45);
+ font-size: 12px;
+}
diff --git a/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts
new file mode 100644
index 00000000000..1e9bf2c686d
--- /dev/null
+++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.spec.ts
@@ -0,0 +1,266 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SimpleChange } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { HttpClientTestingModule } from "@angular/common/http/testing";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { Subject, of, throwError } from "rxjs";
+import { WarehouseCreateModalComponent } from "./warehouse-create-modal.component";
+import { NotificationService } from "../../service/notification/notification.service";
+import { WarehouseActionsService } from "../../service/warehouse/warehouse-actions.service";
+import { DashboardWarehouse } from "../../type/warehouse";
+import { commonTestProviders } from "../../testing/test-utils";
+
+describe("WarehouseCreateModalComponent", () => {
+ let fixture: ComponentFixture;
+ let component: WarehouseCreateModalComponent;
+ let warehouseActions: { create: ReturnType };
+ let notificationService: { error: ReturnType; success: ReturnType };
+
+ const created: DashboardWarehouse = {
+ whid: 7,
+ name: "mybucket",
+ lakekeeperWarehouseName: "user-1-mybucket",
+ flavor: "local",
+ createdAtMillis: 0,
+ ownerName: "Alice",
+ ownerAvatar: "",
+ };
+
+ beforeEach(async () => {
+ warehouseActions = { create: vi.fn().mockReturnValue(of(created)) };
+ notificationService = { error: vi.fn(), success: vi.fn() };
+
+ await TestBed.configureTestingModule({
+ imports: [WarehouseCreateModalComponent, NoopAnimationsModule, HttpClientTestingModule],
+ providers: [
+ // The rendered injects NzModalService itself.
+ NzModalService,
+ { provide: WarehouseActionsService, useValue: warehouseActions },
+ { provide: NotificationService, useValue: notificationService },
+ ...commonTestProviders,
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(WarehouseCreateModalComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ fixture?.destroy();
+ });
+
+ it("renders nothing while closed", () => {
+ // detectChanges already ran in beforeEach with visible=false.
+ expect(document.querySelector("#confirm-create-warehouse-btn")).toBeNull();
+ });
+
+ it("renders the name input and a disabled Create button once opened", () => {
+ component.visible = true;
+ fixture.detectChanges();
+
+ // nz-modal renders into the CDK overlay on document, not into the fixture.
+ expect(document.querySelector("input[nz-input]")).toBeTruthy();
+ const createButton = document.querySelector("#confirm-create-warehouse-btn");
+ expect(createButton?.disabled).toBe(true);
+
+ component.newWarehouseName = "mybucket";
+ fixture.detectChanges();
+ expect(createButton?.disabled).toBe(false);
+ });
+
+ it("drives create from the dialog's own controls, not just the component method", async () => {
+ // The buttons and the Enter key are the only paths a user has; asserting on
+ // the component method alone leaves those bindings unverified.
+ component.visible = true;
+ component.newWarehouseName = "mybucket";
+ fixture.detectChanges();
+
+ document.querySelector("#confirm-create-warehouse-btn")!.click();
+ expect(warehouseActions.create).toHaveBeenCalledWith("mybucket");
+
+ warehouseActions.create.mockClear();
+ component.visible = true;
+ component.newWarehouseName = "again";
+ fixture.detectChanges();
+ document
+ .querySelector("input[nz-input]")!
+ .dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true }));
+ expect(warehouseActions.create).toHaveBeenCalledWith("again");
+ });
+
+ it("takes what the user types through the two-way binding", async () => {
+ component.visible = true;
+ fixture.detectChanges();
+
+ const input = document.querySelector("input[nz-input]")!;
+ input.value = "typed-in";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ await fixture.whenStable();
+
+ expect(component.newWarehouseName).toBe("typed-in");
+ });
+
+ it("closes from the dialog's Cancel button", () => {
+ const visibleSpy = vi.fn();
+ component.visibleChange.subscribe(visibleSpy);
+ component.visible = true;
+ fixture.detectChanges();
+
+ const cancel = Array.from(document.querySelectorAll("button")).find(
+ b => b.textContent?.trim() === "Cancel"
+ )!;
+ cancel.click();
+
+ expect(component.visible).toBe(false);
+ expect(visibleSpy).toHaveBeenCalledWith(false);
+ expect(warehouseActions.create).not.toHaveBeenCalled();
+ });
+
+ it("leaves the form alone when a change does not open the dialog", () => {
+ component.newWarehouseName = "typing";
+
+ component.ngOnChanges({});
+
+ expect(component.newWarehouseName).toBe("typing");
+ });
+
+ it("creates the trimmed name, then emits the warehouse and closes", () => {
+ const createdSpy = vi.fn();
+ const visibleSpy = vi.fn();
+ component.warehouseCreated.subscribe(createdSpy);
+ component.visibleChange.subscribe(visibleSpy);
+ component.visible = true;
+ component.newWarehouseName = " mybucket ";
+
+ component.createWarehouse();
+
+ expect(warehouseActions.create).toHaveBeenCalledWith("mybucket");
+ expect(notificationService.success).toHaveBeenCalledWith('Warehouse "mybucket" created.');
+ expect(createdSpy).toHaveBeenCalledWith(created);
+ expect(component.visible).toBe(false);
+ expect(visibleSpy).toHaveBeenCalledWith(false);
+ expect(component.creating).toBe(false);
+ });
+
+ it("does nothing for a blank name", () => {
+ component.newWarehouseName = " ";
+
+ component.createWarehouse();
+
+ expect(warehouseActions.create).not.toHaveBeenCalled();
+ });
+
+ it("does not double-submit while a create is in flight", () => {
+ component.newWarehouseName = "mybucket";
+ component.creating = true;
+
+ component.createWarehouse();
+
+ expect(warehouseActions.create).not.toHaveBeenCalled();
+ });
+
+ it("keeps the modal open and surfaces the backend message when the create fails", () => {
+ warehouseActions.create.mockReturnValue(
+ throwError(() => ({ error: "a warehouse named 'mybucket' already exists" }))
+ );
+ const visibleSpy = vi.fn();
+ component.visibleChange.subscribe(visibleSpy);
+ component.visible = true;
+ component.newWarehouseName = "mybucket";
+
+ component.createWarehouse();
+
+ expect(component.visible).toBe(true);
+ expect(visibleSpy).not.toHaveBeenCalled();
+ expect(component.creating).toBe(false);
+ expect(notificationService.error).toHaveBeenCalledWith(
+ "Failed to create warehouse: a warehouse named 'mybucket' already exists"
+ );
+ });
+
+ it("clears the previous name and any stuck loading state when the modal opens", () => {
+ component.newWarehouseName = "leftover";
+ // Cancelling mid-flight leaves creating set; reopening must not show a Create
+ // button stuck in its loading state.
+ component.creating = true;
+ component.visible = true;
+
+ component.ngOnChanges({ visible: new SimpleChange(false, true, false) });
+
+ expect(component.newWarehouseName).toBe("");
+ expect(component.creating).toBe(false);
+ });
+
+ it("cancel abandons an in-flight create instead of letting it land later", () => {
+ // The component outlives the dialog, so without an explicit teardown the
+ // request would still succeed: creating the warehouse the user cancelled and
+ // closing the dialog they had already reopened.
+ const inFlight = new Subject();
+ warehouseActions.create.mockReturnValue(inFlight.asObservable());
+ const createdSpy = vi.fn();
+ component.warehouseCreated.subscribe(createdSpy);
+ component.visible = true;
+ component.newWarehouseName = "first";
+ component.createWarehouse();
+
+ component.handleCreateWarehouseModalCancel();
+ inFlight.next(created);
+ inFlight.complete();
+
+ expect(createdSpy).not.toHaveBeenCalled();
+ expect(notificationService.success).not.toHaveBeenCalled();
+ expect(component.creating).toBe(false);
+ });
+
+ it("a host-driven close abandons the in-flight create like Cancel does", () => {
+ // The workspace picker (#7817) will close this dialog by flipping
+ // [(visible)] itself, without going through the Cancel handler.
+ const inFlight = new Subject();
+ warehouseActions.create.mockReturnValue(inFlight.asObservable());
+ const createdSpy = vi.fn();
+ component.warehouseCreated.subscribe(createdSpy);
+ component.visible = true;
+ component.newWarehouseName = "first";
+ component.createWarehouse();
+
+ component.visible = false;
+ component.ngOnChanges({ visible: new SimpleChange(true, false, false) });
+ inFlight.next(created);
+ inFlight.complete();
+
+ expect(createdSpy).not.toHaveBeenCalled();
+ expect(notificationService.success).not.toHaveBeenCalled();
+ });
+
+ it("cancel closes without creating", () => {
+ const visibleSpy = vi.fn();
+ component.visibleChange.subscribe(visibleSpy);
+ component.visible = true;
+
+ component.handleCreateWarehouseModalCancel();
+
+ expect(component.visible).toBe(false);
+ expect(visibleSpy).toHaveBeenCalledWith(false);
+ expect(warehouseActions.create).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts
new file mode 100644
index 00000000000..3f5419b0725
--- /dev/null
+++ b/frontend/src/app/common/component/warehouse-create-modal/warehouse-create-modal.component.ts
@@ -0,0 +1,122 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from "@angular/core";
+import { FormsModule } from "@angular/forms";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { Subject, takeUntil } from "rxjs";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { NzInputDirective } from "ng-zorro-antd/input";
+import { NzModalComponent } from "ng-zorro-antd/modal";
+import { NotificationService } from "../../service/notification/notification.service";
+import { WarehouseActionsService } from "../../service/warehouse/warehouse-actions.service";
+import { DashboardWarehouse } from "../../type/warehouse";
+import { extractErrorMessage } from "../../util/error";
+
+/**
+ * Shared create-warehouse modal (#6933), embedded the same way
+ * ComputingUnitCreateModalComponent is — two-way `[(visible)]` controls the
+ * dialog and `(warehouseCreated)` returns the created warehouse — by the
+ * dashboard tab today and by the workspace picker once it lands (#7817).
+ */
+@UntilDestroy()
+@Component({
+ selector: "texera-warehouse-create-modal",
+ templateUrl: "./warehouse-create-modal.component.html",
+ styleUrls: ["./warehouse-create-modal.component.scss"],
+ imports: [
+ FormsModule,
+ NzModalComponent,
+ NzButtonComponent,
+ NzWaveDirective,
+ ɵNzTransitionPatchDirective,
+ NzInputDirective,
+ ],
+})
+export class WarehouseCreateModalComponent implements OnChanges {
+ // Must be bound two-way ([(visible)]): the modal closes itself.
+ @Input() visible = false;
+ @Output() visibleChange = new EventEmitter();
+ @Output() warehouseCreated = new EventEmitter();
+
+ newWarehouseName = "";
+ creating = false;
+
+ // Closing the dialog ends the attempt it was showing. Without this the request
+ // outlives the dialog (the component itself is never torn down), so Cancel
+ // would still create the warehouse and a late response would close — and
+ // discard — whatever the user had typed after reopening.
+ private readonly closed$ = new Subject();
+
+ constructor(
+ private warehouseActionsService: WarehouseActionsService,
+ private notificationService: NotificationService
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes["visible"]?.currentValue === true) {
+ this.newWarehouseName = "";
+ // Cancelling mid-flight leaves creating set; without this the Create button
+ // reopens stuck in its loading state.
+ this.creating = false;
+ } else if (changes["visible"]?.currentValue === false) {
+ // The host can also close the dialog by flipping [(visible)] itself; that
+ // close must abandon the in-flight attempt exactly like Cancel does, or a
+ // late response would close — and discard — a reopened dialog.
+ this.closed$.next();
+ }
+ }
+
+ createWarehouse(): void {
+ const name = this.newWarehouseName.trim();
+ if (!name || this.creating) {
+ return;
+ }
+ this.creating = true;
+ this.warehouseActionsService
+ .create(name)
+ .pipe(takeUntil(this.closed$), untilDestroyed(this))
+ .subscribe({
+ next: created => {
+ this.creating = false;
+ this.notificationService.success(`Warehouse "${created.name}" created.`);
+ this.warehouseCreated.emit(created);
+ this.closeModal();
+ },
+ error: (err: unknown) => {
+ // Keep the modal open so the name can be corrected.
+ this.creating = false;
+ this.notificationService.error(`Failed to create warehouse: ${extractErrorMessage(err)}`);
+ },
+ });
+ }
+
+ handleCreateWarehouseModalCancel(): void {
+ this.closeModal();
+ }
+
+ private closeModal(): void {
+ this.closed$.next();
+ this.creating = false;
+ this.visible = false;
+ this.visibleChange.emit(false);
+ }
+}
diff --git a/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.spec.ts b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.spec.ts
new file mode 100644
index 00000000000..dbb294b71e0
--- /dev/null
+++ b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.spec.ts
@@ -0,0 +1,70 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { TestBed } from "@angular/core/testing";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+import { WarehouseMetadataComponent } from "./warehouse-metadata.component";
+import { DashboardWarehouse } from "../../type/warehouse";
+
+describe("WarehouseMetadataComponent", () => {
+ const warehouse: DashboardWarehouse = {
+ whid: 3,
+ name: "sales",
+ lakekeeperWarehouseName: "user-1-sales",
+ flavor: "local",
+ createdAtMillis: 1723300000000,
+ ownerName: "Alice",
+ ownerAvatar: "",
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [WarehouseMetadataComponent],
+ providers: [{ provide: NZ_MODAL_DATA, useValue: warehouse }],
+ }).compileComponents();
+ });
+
+ it("renders every field of the injected warehouse", () => {
+ const fixture = TestBed.createComponent(WarehouseMetadataComponent);
+ fixture.detectChanges();
+
+ const text = fixture.nativeElement.textContent;
+ expect(text).toContain("sales");
+ expect(text).toContain("Alice");
+ expect(text).toContain("local");
+ // The absolute timestamp is locale-dependent; assert against the same
+ // conversion the component performs rather than a hard-coded string.
+ expect(text).toContain(new Date(1723300000000).toLocaleString());
+ });
+
+ it("shows None for an owner with no display name", () => {
+ TestBed.overrideProvider(NZ_MODAL_DATA, { useValue: { ...warehouse, ownerName: null } });
+ const fixture = TestBed.createComponent(WarehouseMetadataComponent);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain("None");
+ });
+
+ it("leaves out the catalog name, which locates data the user cannot reach yet", () => {
+ const fixture = TestBed.createComponent(WarehouseMetadataComponent);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).not.toContain("user-1-sales");
+ });
+});
diff --git a/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.ts b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.ts
new file mode 100644
index 00000000000..83d9abcce50
--- /dev/null
+++ b/frontend/src/app/common/component/warehouse-metadata/warehouse-metadata.component.ts
@@ -0,0 +1,61 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, inject } from "@angular/core";
+import { NZ_MODAL_DATA } from "ng-zorro-antd/modal";
+import { DashboardWarehouse } from "../../type/warehouse";
+
+/**
+ * Read-only warehouse details dialog (#6933), mirroring
+ * ComputingUnitMetadataComponent. Opened via NzModalService with the
+ * warehouse as NZ_MODAL_DATA.
+ *
+ * The Lakekeeper catalog name is deliberately left out: it is an internal
+ * catalog identifier that happens to double as the storage key prefix. What a
+ * user could act on, once warehouses live in their own bucket (#6870, Phase 1),
+ * is the storage location itself — a separate field.
+ */
+@Component({
+ template: `
+
+
diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss
new file mode 100644
index 00000000000..2f087ef2ff6
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.scss
@@ -0,0 +1,105 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+@use "../../../section-style" as *;
+@use "../../../dashboard.component.scss" as *;
+
+.warehouse-list-item-card {
+ padding: 3px;
+ width: 100%;
+ background-color: white;
+ position: relative;
+ min-height: 65px;
+ height: auto;
+
+ &:hover {
+ background-color: #f0f0f0;
+ }
+}
+
+// The computing-unit row is 64px of content: its two 32px metric bars drive
+// the height. A warehouse row has no metrics, so pin the same content height
+// here — card paddings and borders already match, so the rows line up exactly.
+.warehouse-item-row {
+ min-height: 64px;
+}
+
+.warehouse-list-item-card:hover .button-group {
+ display: flex;
+ background-color: transparent;
+}
+
+.type-icon {
+ font-size: 30px;
+}
+
+.warehouse-id {
+ padding: 6px;
+}
+
+.resource-name-group {
+ min-width: 0;
+}
+
+.resource-name {
+ font-size: 17px;
+ font-weight: 600;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.resource-name:hover {
+ text-decoration: underline;
+}
+
+.resource-info {
+ font-size: 13px;
+ color: grey;
+}
+
+.truncate-single-line {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+}
+
+.button-group {
+ display: none;
+ position: absolute;
+ height: 70px;
+ min-width: 150px;
+ right: 0;
+ bottom: 0;
+ justify-content: right;
+ align-items: center;
+ transition: none;
+ z-index: 10;
+
+ button {
+ margin-right: 32px;
+ transition: none;
+ background-color: #e0e0e0;
+ border: 1px solid #d0d0d0;
+ border-radius: 8px;
+ }
+
+ button:hover {
+ background-color: #c7c7c7;
+ }
+}
diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.spec.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.spec.ts
new file mode 100644
index 00000000000..d62a168d6ac
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.spec.ts
@@ -0,0 +1,98 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { NzModalRef, NzModalService } from "ng-zorro-antd/modal";
+import { CloudServerOutline, DeleteOutline } from "@ant-design/icons-angular/icons";
+import { UserWarehouseListItemComponent } from "./user-warehouse-list-item.component";
+import { WarehouseMetadataComponent } from "../../../../../common/component/warehouse-metadata/warehouse-metadata.component";
+import { DashboardWarehouse } from "../../../../../common/type/warehouse";
+import { commonTestProviders } from "../../../../../common/testing/test-utils";
+
+describe("UserWarehouseListItemComponent", () => {
+ let fixture: ComponentFixture;
+
+ const warehouse: DashboardWarehouse = {
+ whid: 3,
+ name: "sales",
+ lakekeeperWarehouseName: "user-1-sales",
+ flavor: "local",
+ createdAtMillis: 0,
+ ownerName: "Alice",
+ ownerAvatar: "",
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [UserWarehouseListItemComponent, NzIconModule.forChild([CloudServerOutline, DeleteOutline])],
+ providers: [NzModalService, ...commonTestProviders],
+ }).compileComponents();
+ });
+
+ it("throws when rendered without a warehouse", () => {
+ const item = new UserWarehouseListItemComponent({} as NzModalService);
+
+ expect(() => item.warehouse).toThrowError("warehouse property must be provided to UserWarehouseListItemComponent.");
+ });
+
+ it("renders the id, the name, and the metadata columns", () => {
+ fixture = TestBed.createComponent(UserWarehouseListItemComponent);
+ fixture.componentInstance.warehouse = warehouse;
+ fixture.detectChanges();
+
+ const element: HTMLElement = fixture.nativeElement;
+ expect(element.querySelector(".warehouse-id")?.textContent).toContain("#3");
+ expect(element.querySelector(".resource-name")?.textContent).toContain("sales");
+ // The relative "Created" value depends on the clock; assert the labels only.
+ expect(element.textContent).toContain("Created:");
+ expect(element.textContent).toContain("Flavor:");
+ expect(element.textContent).toContain("local");
+ });
+
+ it("emits deleted when the delete button is clicked", () => {
+ fixture = TestBed.createComponent(UserWarehouseListItemComponent);
+ fixture.componentInstance.warehouse = warehouse;
+ fixture.detectChanges();
+ const deletedSpy = vi.fn();
+ fixture.componentInstance.deleted.subscribe(deletedSpy);
+
+ fixture.debugElement.query(By.css(".button-group button")).triggerEventHandler("click", null);
+
+ expect(deletedSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("opens the metadata modal when the name is clicked", () => {
+ fixture = TestBed.createComponent(UserWarehouseListItemComponent);
+ fixture.componentInstance.warehouse = warehouse;
+ fixture.detectChanges();
+ const createSpy = vi.spyOn(TestBed.inject(NzModalService), "create").mockReturnValue({} as NzModalRef);
+
+ fixture.debugElement.query(By.css(".resource-name")).triggerEventHandler("click", null);
+
+ expect(createSpy).toHaveBeenCalledTimes(1);
+ const config = createSpy.mock.calls[0][0];
+ expect(config.nzTitle).toBe("Warehouse Information");
+ // The content component is what the dialog actually shows.
+ expect(config.nzContent).toBe(WarehouseMetadataComponent);
+ expect(config.nzData).toEqual(warehouse);
+ expect(config.nzFooter).toBeNull();
+ });
+});
diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts
new file mode 100644
index 00000000000..dcaa780a3c1
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse-list-item/user-warehouse-list-item.component.ts
@@ -0,0 +1,81 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, EventEmitter, Input, Output } from "@angular/core";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { WarehouseMetadataComponent } from "../../../../../common/component/warehouse-metadata/warehouse-metadata.component";
+import { DashboardWarehouse } from "../../../../../common/type/warehouse";
+import { formatRelativeTime } from "../../../../../common/util/format.util";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzCardComponent } from "ng-zorro-antd/card";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzRowDirective, NzColDirective } from "ng-zorro-antd/grid";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
+
+/**
+ * One warehouse row of the dashboard tab (#6933), mirroring
+ * UserComputingUnitListItemComponent: the row only emits `deleted`; the
+ * containing list owns the confirmation dialog.
+ */
+@Component({
+ selector: "texera-user-warehouse-list-item",
+ templateUrl: "./user-warehouse-list-item.component.html",
+ styleUrls: ["./user-warehouse-list-item.component.scss"],
+ imports: [
+ NzCardComponent,
+ NzRowDirective,
+ NzColDirective,
+ ɵNzTransitionPatchDirective,
+ NzIconDirective,
+ NzSpaceCompactItemDirective,
+ NzButtonComponent,
+ ],
+})
+export class UserWarehouseListItemComponent {
+ private _warehouse?: DashboardWarehouse;
+ @Output() deleted = new EventEmitter();
+
+ @Input()
+ get warehouse(): DashboardWarehouse {
+ if (!this._warehouse) {
+ throw new Error("warehouse property must be provided to UserWarehouseListItemComponent.");
+ }
+ return this._warehouse;
+ }
+
+ set warehouse(value: DashboardWarehouse) {
+ this._warehouse = value;
+ }
+
+ constructor(private modalService: NzModalService) {}
+
+ openWarehouseMetadataModal(): void {
+ this.modalService.create({
+ nzTitle: "Warehouse Information",
+ nzContent: WarehouseMetadataComponent,
+ nzData: this.warehouse,
+ nzFooter: null,
+ nzMaskClosable: true,
+ nzWidth: "600px",
+ });
+ }
+
+ formatRelativeTime = formatRelativeTime;
+}
diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.html b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.html
new file mode 100644
index 00000000000..5de331f13a9
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.html
@@ -0,0 +1,84 @@
+
+
+
+
+
Warehouses
+
+
+
+
+
+
+
+ Could not load your warehouses.
+
+
+
+
+ Per-user warehouses are disabled in this deployment.
+
+
+
+ No warehouses yet. Click Create Warehouse to create one.
+
+
+
+
diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.scss b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.scss
new file mode 100644
index 00000000000..95cecefe8c0
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.scss
@@ -0,0 +1,37 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+@use "../../dashboard.component.scss" as *;
+@use "../../section-style" as *;
+@use "../../button-style" as *;
+
+.subsection-grid-container {
+ min-width: 100%;
+ width: 100%;
+ min-height: 100%;
+ height: 100%;
+}
+
+.warehouse-page-empty {
+ padding: 24px;
+ color: rgba(0, 0, 0, 0.55);
+ text-align: center;
+ border: 1px dashed #d9d9d9;
+ border-radius: 4px;
+}
diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.spec.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.spec.ts
new file mode 100644
index 00000000000..4c5c5ac0dd1
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.spec.ts
@@ -0,0 +1,282 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { By } from "@angular/platform-browser";
+import { NoopAnimationsModule } from "@angular/platform-browser/animations";
+import { CloudServerOutline, DeleteOutline, FileAddOutline } from "@ant-design/icons-angular/icons";
+import { NzIconModule } from "ng-zorro-antd/icon";
+import { NzModalService } from "ng-zorro-antd/modal";
+import { NEVER, Subject, of, throwError } from "rxjs";
+
+import { UserWarehouseComponent } from "./user-warehouse.component";
+import { WarehouseCreateModalComponent } from "../../../../common/component/warehouse-create-modal/warehouse-create-modal.component";
+import { NotificationService } from "../../../../common/service/notification/notification.service";
+import { WarehouseActionsService } from "../../../../common/service/warehouse/warehouse-actions.service";
+import { WarehouseService } from "../../../../common/service/warehouse/warehouse.service";
+import { DashboardWarehouse } from "../../../../common/type/warehouse";
+import { commonTestProviders } from "../../../../common/testing/test-utils";
+
+describe("UserWarehouseComponent", () => {
+ let component: UserWarehouseComponent;
+ let fixture: ComponentFixture;
+
+ let warehouseServiceSpy: {
+ getStatus: ReturnType;
+ createWarehouse: ReturnType;
+ deleteWarehouse: ReturnType;
+ };
+ let notificationSpy: {
+ error: ReturnType;
+ success: ReturnType;
+ info: ReturnType;
+ warning: ReturnType;
+ };
+ let consoleErrorSpy: ReturnType;
+
+ const warehouse = (whid: number, name: string): DashboardWarehouse => ({
+ whid,
+ name,
+ lakekeeperWarehouseName: `user-1-${name}`,
+ flavor: "local",
+ createdAtMillis: 1723300000000,
+ ownerName: "Alice",
+ ownerAvatar: "",
+ });
+
+ beforeEach(async () => {
+ warehouseServiceSpy = {
+ getStatus: vi.fn().mockReturnValue(of({ enabled: true, warehouses: [] })),
+ createWarehouse: vi.fn().mockReturnValue(of(warehouse(1, "mybucket"))),
+ deleteWarehouse: vi.fn().mockReturnValue(of(undefined)),
+ };
+ notificationSpy = {
+ error: vi.fn(),
+ success: vi.fn(),
+ info: vi.fn(),
+ warning: vi.fn(),
+ };
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ await TestBed.configureTestingModule({
+ imports: [
+ UserWarehouseComponent,
+ NoopAnimationsModule,
+ NzIconModule.forChild([FileAddOutline, CloudServerOutline, DeleteOutline]),
+ ],
+ providers: [
+ NzModalService,
+ { provide: WarehouseService, useValue: warehouseServiceSpy as unknown as WarehouseService },
+ { provide: NotificationService, useValue: notificationSpy as unknown as NotificationService },
+ ...commonTestProviders,
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(UserWarehouseComponent);
+ component = fixture.componentInstance;
+ });
+
+ afterEach(() => {
+ consoleErrorSpy?.mockRestore();
+ fixture?.destroy();
+ });
+
+ it("shows the disabled notice when the deployment reports the feature off", () => {
+ warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: false, warehouses: [] }));
+
+ fixture.detectChanges();
+
+ expect(component.warehouseEnabled).toBe(false);
+ const notice = fixture.nativeElement.querySelector(".warehouse-page-empty");
+ expect(notice?.textContent).toContain("disabled in this deployment");
+ expect(fixture.nativeElement.querySelectorAll("texera-user-warehouse-list-item").length).toBe(0);
+ });
+
+ it("says nothing about the feature while the status request is still in flight", () => {
+ // A plain false would render the "disabled in this deployment" notice, naming
+ // the wrong cause while the request is pending or after it failed.
+ warehouseServiceSpy.getStatus.mockReturnValue(NEVER);
+
+ fixture.detectChanges();
+
+ expect(component.warehouseEnabled).toBeUndefined();
+ expect(fixture.nativeElement.querySelector(".warehouse-page-empty")).toBeNull();
+ });
+
+ it("shows the create hint while the user has no warehouses", () => {
+ fixture.detectChanges();
+
+ const notice = fixture.nativeElement.querySelector(".warehouse-page-empty");
+ expect(notice?.textContent).toContain("No warehouses yet");
+ expect(fixture.nativeElement.querySelectorAll("texera-user-warehouse-list-item").length).toBe(0);
+ });
+
+ it("lists the user's warehouses in the virtual-scroll list", () => {
+ warehouseServiceSpy.getStatus.mockReturnValue(
+ of({ enabled: true, warehouses: [warehouse(1, "first"), warehouse(2, "second")] })
+ );
+
+ fixture.detectChanges();
+
+ // The virtual-scroll viewport has no height in jsdom, so no rows are laid
+ // out here; the row markup is covered by the list item's own spec. What this
+ // asserts is the state the template iterates and the absence of any notice.
+ expect(component.warehouses.map(w => w.whid)).toEqual([1, 2]);
+ expect(fixture.nativeElement.querySelector(".warehouse-page-empty")).toBeNull();
+ });
+
+ it("renders the scroll viewport only when there are rows to show", () => {
+ // In the disabled/failed/empty states the viewport would otherwise render
+ // as an empty bordered box below the notice.
+ warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: false, warehouses: [] }));
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector("cdk-virtual-scroll-viewport")).toBeNull();
+
+ warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [warehouse(1, "first")] }));
+ component.retry();
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector("cdk-virtual-scroll-viewport")).toBeTruthy();
+ });
+
+ it("opens the create modal from the header button, which is disabled while the feature is off", () => {
+ warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: false, warehouses: [] }));
+ fixture.detectChanges();
+
+ const createButton = fixture.debugElement.query(By.css(".create-btn"));
+ expect(createButton.nativeElement.disabled).toBe(true);
+
+ warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [] }));
+ component.ngOnInit();
+ fixture.detectChanges();
+ expect(createButton.nativeElement.disabled).toBe(false);
+
+ createButton.triggerEventHandler("click", null);
+ fixture.detectChanges();
+
+ expect(component.addWarehouseModalVisible).toBe(true);
+ // The flag has to reach the embedded modal, not just the component field.
+ expect(fixture.debugElement.query(By.directive(WarehouseCreateModalComponent)).componentInstance.visible).toBe(
+ true
+ );
+ });
+
+ it("offers a retry when the status request fails, and recovers on success", () => {
+ warehouseServiceSpy.getStatus.mockReturnValue(throwError(() => new Error("boom")));
+ fixture.detectChanges();
+
+ expect(component.loadFailed).toBe(true);
+ const notice = fixture.nativeElement.querySelector(".warehouse-page-empty");
+ expect(notice?.textContent).toContain("Could not load your warehouses");
+
+ warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [warehouse(1, "first")] }));
+ fixture.debugElement.query(By.css(".warehouse-page-empty button")).triggerEventHandler("click", null);
+ fixture.detectChanges();
+
+ expect(component.loadFailed).toBe(false);
+ expect(component.warehouses.map(w => w.whid)).toEqual([1]);
+ });
+
+ it("clears the stale status when a later refresh fails, leaving only the retry state", () => {
+ // Otherwise a failed refresh right after a delete would show the failure
+ // notice beside an outdated list still containing the deleted row, with
+ // Create still enabled.
+ warehouseServiceSpy.getStatus.mockReturnValue(of({ enabled: true, warehouses: [warehouse(1, "first")] }));
+ fixture.detectChanges();
+ expect(component.warehouses.length).toBe(1);
+
+ warehouseServiceSpy.getStatus.mockReturnValue(throwError(() => new Error("boom")));
+ component.retry();
+ fixture.detectChanges();
+
+ expect(component.warehouseEnabled).toBeUndefined();
+ expect(component.warehouses).toEqual([]);
+ const notices = fixture.nativeElement.querySelectorAll(".warehouse-page-empty");
+ expect(notices.length).toBe(1);
+ expect(notices[0].textContent).toContain("Could not load your warehouses");
+ expect(fixture.debugElement.query(By.css(".create-btn")).nativeElement.disabled).toBe(true);
+ });
+
+ it("a late response from a superseded refresh cannot overwrite the newer state", () => {
+ const first = new Subject<{ enabled: boolean; warehouses: DashboardWarehouse[] }>();
+ const second = new Subject<{ enabled: boolean; warehouses: DashboardWarehouse[] }>();
+ warehouseServiceSpy.getStatus.mockReturnValueOnce(first.asObservable()).mockReturnValueOnce(second.asObservable());
+
+ fixture.detectChanges();
+ component.retry();
+
+ second.next({ enabled: true, warehouses: [warehouse(2, "kept")] });
+ second.complete();
+ // The older request settles last; switchMap must already have dropped it.
+ first.next({ enabled: true, warehouses: [warehouse(1, "stale")] });
+ first.complete();
+
+ expect(component.warehouses.map(w => w.name)).toEqual(["kept"]);
+ });
+
+ it("surfaces a failed status fetch as a notification", () => {
+ warehouseServiceSpy.getStatus.mockReturnValue(throwError(() => new Error("boom")));
+
+ fixture.detectChanges();
+
+ expect(notificationSpy.error).toHaveBeenCalledWith("Failed to fetch warehouses.");
+ // Still undefined, so the page reports the failure rather than claiming the
+ // feature is disabled.
+ expect(component.warehouseEnabled).toBeUndefined();
+ expect(fixture.nativeElement.querySelector(".warehouse-page-empty")?.textContent).toContain(
+ "Could not load your warehouses"
+ );
+ });
+
+ it("hands the warehouse to the actions service, and refreshes once it reports the delete", () => {
+ const actionsService = TestBed.inject(WarehouseActionsService);
+ const confirmAndDeleteSpy = vi.spyOn(actionsService, "confirmAndDelete").mockImplementation(() => {});
+ fixture.detectChanges();
+ warehouseServiceSpy.getStatus.mockClear();
+ const doomed = warehouse(3, "doomed");
+
+ component.deleteWarehouse(doomed);
+
+ expect(confirmAndDeleteSpy).toHaveBeenCalledTimes(1);
+ expect(confirmAndDeleteSpy.mock.calls[0][0]).toEqual(doomed);
+ const onDeleted = confirmAndDeleteSpy.mock.calls[0][1] as () => void;
+ onDeleted();
+ expect(warehouseServiceSpy.getStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes the warehouse list when the modal emits warehouseCreated", () => {
+ fixture.detectChanges();
+ warehouseServiceSpy.getStatus.mockClear();
+
+ const modal = fixture.debugElement.query(By.directive(WarehouseCreateModalComponent)).componentInstance;
+ modal.warehouseCreated.emit(warehouse(1, "mybucket"));
+
+ expect(warehouseServiceSpy.getStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it("syncs visibility when the embedded modal closes itself", () => {
+ fixture.detectChanges();
+ component.showAddWarehouseModalVisible();
+ expect(component.addWarehouseModalVisible).toBe(true);
+
+ const modal = fixture.debugElement.query(By.directive(WarehouseCreateModalComponent)).componentInstance;
+ modal.visibleChange.emit(false);
+
+ expect(component.addWarehouseModalVisible).toBe(false);
+ });
+});
diff --git a/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.ts b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.ts
new file mode 100644
index 00000000000..847de6f3d7a
--- /dev/null
+++ b/frontend/src/app/dashboard/component/user/user-warehouse/user-warehouse.component.ts
@@ -0,0 +1,142 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, OnInit } from "@angular/core";
+import { NgIf } from "@angular/common";
+import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
+import { EMPTY, Subject, catchError, switchMap } from "rxjs";
+
+import { ɵɵCdkVirtualScrollViewport, ɵɵCdkFixedSizeVirtualScroll, ɵɵCdkVirtualForOf } from "@angular/cdk/overlay";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzCardComponent } from "ng-zorro-antd/card";
+import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
+import { NzWaveDirective } from "ng-zorro-antd/core/wave";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { NzListComponent } from "ng-zorro-antd/list";
+import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
+
+import { WarehouseCreateModalComponent } from "../../../../common/component/warehouse-create-modal/warehouse-create-modal.component";
+import { NotificationService } from "../../../../common/service/notification/notification.service";
+import { WarehouseActionsService } from "../../../../common/service/warehouse/warehouse-actions.service";
+import { WarehouseService } from "../../../../common/service/warehouse/warehouse.service";
+import { DashboardWarehouse } from "../../../../common/type/warehouse";
+import { UserWarehouseListItemComponent } from "./user-warehouse-list-item/user-warehouse-list-item.component";
+
+/**
+ * Dashboard page for per-user warehouses (#6933), mirroring
+ * UserComputingUnitComponent: list the caller's warehouses, create one (Local
+ * flavor), delete one. Reachable only while the deployment reports the feature
+ * enabled; the page re-checks and says so otherwise.
+ */
+@UntilDestroy()
+@Component({
+ selector: "texera-user-warehouse",
+ templateUrl: "./user-warehouse.component.html",
+ styleUrls: ["./user-warehouse.component.scss"],
+ imports: [
+ NgIf,
+ NzCardComponent,
+ NzSpaceCompactItemDirective,
+ NzButtonComponent,
+ NzWaveDirective,
+ ɵNzTransitionPatchDirective,
+ NzIconDirective,
+ ɵɵCdkVirtualScrollViewport,
+ ɵɵCdkFixedSizeVirtualScroll,
+ NzListComponent,
+ ɵɵCdkVirtualForOf,
+ UserWarehouseListItemComponent,
+ WarehouseCreateModalComponent,
+ ],
+})
+export class UserWarehouseComponent implements OnInit {
+ // Undefined until the status request settles: with a plain false, a pending or
+ // failed request renders the "disabled in this deployment" notice, which names
+ // the wrong cause.
+ warehouseEnabled?: boolean;
+ warehouses: DashboardWarehouse[] = [];
+ // The status request failed: without this the page renders neither the
+ // disabled notice nor the list, leaving a blank card and no way back.
+ loadFailed = false;
+
+ // visibility of the shared create-warehouse modal
+ addWarehouseModalVisible = false;
+
+ constructor(
+ private warehouseService: WarehouseService,
+ private notificationService: NotificationService,
+ private warehouseActionsService: WarehouseActionsService
+ ) {}
+
+ // All refreshes flow through one switchMap'd stream: a new request cancels
+ // the in-flight one, so a response arriving late can never overwrite newer
+ // state (say, resurrecting a warehouse a later refresh saw deleted).
+ private readonly refreshRequested$ = new Subject();
+
+ ngOnInit(): void {
+ this.refreshRequested$
+ .pipe(
+ switchMap(() =>
+ this.warehouseService.getStatus().pipe(
+ // Caught inside the switchMap so a failure ends only this request,
+ // not the stream — Retry must still work afterwards.
+ catchError((err: unknown) => {
+ this.loadFailed = true;
+ // A failed refresh must not leave the previous answer behind:
+ // stale rows (possibly including a just-deleted warehouse) and an
+ // enabled Create button would render alongside the failure
+ // notice, mixing the states this page promises to keep distinct.
+ this.warehouseEnabled = undefined;
+ this.warehouses = [];
+ console.error("Failed to fetch warehouses", err);
+ this.notificationService.error("Failed to fetch warehouses.");
+ return EMPTY;
+ })
+ )
+ ),
+ untilDestroyed(this)
+ )
+ .subscribe(status => {
+ this.loadFailed = false;
+ this.warehouseEnabled = status.enabled;
+ this.warehouses = [...status.warehouses];
+ });
+ this.refresh();
+ }
+
+ retry(): void {
+ this.refresh();
+ }
+
+ private refresh(): void {
+ this.refreshRequested$.next();
+ }
+
+ deleteWarehouse(warehouse: DashboardWarehouse): void {
+ this.warehouseActionsService.confirmAndDelete(warehouse, () => this.refresh());
+ }
+
+ showAddWarehouseModalVisible(): void {
+ this.addWarehouseModalVisible = true;
+ }
+
+ onWarehouseCreated(): void {
+ this.refresh();
+ }
+}