Merge pull request 'Compact add store control' (#15) from feature/compact-add-store-control into feature-custom-store-locations
This commit is contained in:
commit
27a6a50744
@ -42,11 +42,7 @@ export default function ManageStores() {
|
||||
refreshZones,
|
||||
} = useContext(StoreContext);
|
||||
const toast = useActionToast();
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
location_name: "",
|
||||
address: "",
|
||||
});
|
||||
const [newStoreName, setNewStoreName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
@ -62,18 +58,17 @@ export default function ManageStores() {
|
||||
|
||||
const handleCreateStore = async (event) => {
|
||||
event.preventDefault();
|
||||
if (!createForm.name.trim()) return;
|
||||
const storeName = newStoreName.trim();
|
||||
if (!storeName) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await createHouseholdStore(activeHousehold.id, {
|
||||
name: createForm.name.trim(),
|
||||
location_name: createForm.location_name.trim() || "Default Location",
|
||||
address: createForm.address.trim() || null,
|
||||
name: storeName,
|
||||
});
|
||||
setCreateForm({ name: "", location_name: "", address: "" });
|
||||
setNewStoreName("");
|
||||
await refreshAfterStoreChange();
|
||||
toast.success("Created store", `Created store ${createForm.name.trim()}`);
|
||||
toast.success("Created store", `Created store ${storeName}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to create store");
|
||||
toast.error("Create store failed", `Create store failed: ${message}`);
|
||||
@ -85,7 +80,23 @@ export default function ManageStores() {
|
||||
return (
|
||||
<div className="manage-stores">
|
||||
<section className="manage-section">
|
||||
<div className="manage-stores-topline">
|
||||
<h2>Store Locations ({householdStores.length})</h2>
|
||||
{isAdmin ? (
|
||||
<form className="add-store-inline" onSubmit={handleCreateStore}>
|
||||
<input
|
||||
value={newStoreName}
|
||||
onChange={(event) => setNewStoreName(event.target.value)}
|
||||
placeholder="Store name"
|
||||
aria-label="Store name"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? "Adding..." : "Add"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="manage-stores-help">
|
||||
Stores are private to this household. Locations define map-specific zones, item
|
||||
placement, and shopping order.
|
||||
@ -152,34 +163,7 @@ export default function ManageStores() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{isAdmin ? (
|
||||
<section className="manage-section">
|
||||
<h2>Add Store</h2>
|
||||
<form className="add-store-panel" onSubmit={handleCreateStore}>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Store name, e.g. Costco"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
value={createForm.location_name}
|
||||
onChange={(event) =>
|
||||
setCreateForm((current) => ({ ...current, location_name: event.target.value }))
|
||||
}
|
||||
placeholder="Location name, e.g. Fontana"
|
||||
/>
|
||||
<input
|
||||
value={createForm.address}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, address: event.target.value }))}
|
||||
placeholder="Address or notes"
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? "Adding..." : "+ Add Store"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
) : activeStore ? (
|
||||
{!isAdmin && activeStore ? (
|
||||
<p className="manage-stores-note">
|
||||
Household members can manage item defaults. Only owners and admins can manage stores,
|
||||
locations, zones, and item deletion.
|
||||
|
||||
@ -22,6 +22,14 @@
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manage-stores-topline {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@ -150,7 +158,7 @@
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.add-store-panel input,
|
||||
.add-store-inline input,
|
||||
.store-management-create-row input,
|
||||
.store-settings-form input {
|
||||
width: 100%;
|
||||
@ -237,52 +245,15 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Add Store Panel */
|
||||
.add-store-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.available-stores {
|
||||
.add-store-inline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.available-store-card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.available-store-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.available-store-card .store-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.available-store-card h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.available-store-card .store-location {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
.add-store-inline .btn-primary {
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
@ -295,22 +266,14 @@
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.stores-list,
|
||||
.available-stores {
|
||||
.stores-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.available-store-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.available-store-card button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-card-header,
|
||||
.store-location-controls,
|
||||
.manage-stores-topline,
|
||||
.add-store-inline,
|
||||
.store-items-modal-toolbar.store-management-create-row,
|
||||
.store-items-modal-toolbar.store-location-create-row,
|
||||
.store-management-row {
|
||||
@ -321,6 +284,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-store-inline .btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-management-order {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@ -105,6 +105,11 @@ test("manage stores opens a modal to edit and delete household store items", asy
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Add Store" })).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("Store name")).toBeVisible();
|
||||
await expect(page.getByPlaceholder("Location name")).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder("Address or notes")).toHaveCount(0);
|
||||
|
||||
const storeCard = page.locator(".store-card").filter({ hasText: "Costco" });
|
||||
await expect(storeCard).toBeVisible();
|
||||
await expect(storeCard.getByText("Costco", { exact: true })).toHaveCount(1);
|
||||
|
||||
@ -47,10 +47,15 @@ test("manage stores add success shows success toast", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await mockConfig(page);
|
||||
|
||||
let linkedStoreIds = [10];
|
||||
const allStores = [
|
||||
{ id: 10, name: "Costco North", location: "North", is_default: true },
|
||||
{ id: 11, name: "Costco South", location: "South", is_default: false },
|
||||
let stores = [
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
is_default: true,
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
@ -61,65 +66,63 @@ test("manage stores add success shows success toast", async ({ page }) => {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
const payload = linkedStoreIds.map((id, index) => {
|
||||
const store = allStores.find((candidate) => candidate.id === id);
|
||||
return {
|
||||
...store,
|
||||
is_default: index === 0,
|
||||
};
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(stores),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "POST") {
|
||||
const body = request.postDataJSON() as { storeId?: number };
|
||||
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
|
||||
linkedStoreIds = [...linkedStoreIds, body.storeId];
|
||||
}
|
||||
const body = request.postDataJSON() as { name?: string };
|
||||
const name = body.name || "New Store";
|
||||
stores = [
|
||||
...stores,
|
||||
{
|
||||
id: 11,
|
||||
household_store_id: 101,
|
||||
name,
|
||||
location_name: "Default Location",
|
||||
display_name: name,
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
body: JSON.stringify({ store: stores[stores.length - 1] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(allStores),
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
||||
const addStoreForm = page.locator(".add-store-inline");
|
||||
await addStoreForm.getByLabel("Store name").fill("Stater Bros");
|
||||
await addStoreForm.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Created store");
|
||||
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Stater Bros");
|
||||
});
|
||||
|
||||
test("manage stores add failure shows normalized error toast", async ({ page }) => {
|
||||
await seedAuthStorage(page);
|
||||
await mockConfig(page);
|
||||
|
||||
const allStores = [
|
||||
{ id: 10, name: "Costco North", location: "North", is_default: true },
|
||||
{ id: 11, name: "Costco South", location: "South", is_default: false },
|
||||
];
|
||||
|
||||
await page.route("**/households", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@ -128,13 +131,22 @@ test("manage stores add failure shows normalized error toast", async ({ page })
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/stores/household/1", async (route) => {
|
||||
await page.route("**/households/1/stores", async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: 10,
|
||||
household_store_id: 100,
|
||||
name: "Costco",
|
||||
location_name: "Default Location",
|
||||
display_name: "Costco",
|
||||
is_default: true,
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -150,22 +162,23 @@ test("manage stores add failure shows normalized error toast", async ({ page })
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fallback();
|
||||
await route.fulfill({ status: 405 });
|
||||
});
|
||||
|
||||
await page.route("**/stores", async (route) => {
|
||||
await page.route("**/households/1/locations/10/zones", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(allStores),
|
||||
body: JSON.stringify({ zones: [] }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/manage?tab=stores");
|
||||
await page.getByRole("button", { name: "+ Add Store" }).click();
|
||||
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
|
||||
const addStoreForm = page.locator(".add-store-inline");
|
||||
await addStoreForm.getByLabel("Store name").fill("Costco");
|
||||
await addStoreForm.getByRole("button", { name: "Add" }).click();
|
||||
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed");
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Create store failed");
|
||||
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user