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