Compare commits
No commits in common. "d271a0e34a918c22a846ae10447dce46b813cc09" and "c681312eba8831d3d5976984efda12e439de1b0e" have entirely different histories.
d271a0e34a
...
c681312eba
@ -14,42 +14,6 @@ exports.getUserHouseholds = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.reorderHouseholds = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const rawHouseholdIds = req.body.household_ids || req.body.householdIds;
|
|
||||||
|
|
||||||
if (!Array.isArray(rawHouseholdIds)) {
|
|
||||||
return sendError(res, 400, "household_ids must be an array");
|
|
||||||
}
|
|
||||||
|
|
||||||
const householdIds = rawHouseholdIds.map((householdId) =>
|
|
||||||
Number.parseInt(householdId, 10)
|
|
||||||
);
|
|
||||||
const hasInvalidId = householdIds.some(
|
|
||||||
(householdId) => !Number.isInteger(householdId) || householdId <= 0
|
|
||||||
);
|
|
||||||
const hasDuplicates = new Set(householdIds).size !== householdIds.length;
|
|
||||||
|
|
||||||
if (hasInvalidId || hasDuplicates) {
|
|
||||||
return sendError(res, 400, "household_ids must contain unique positive household IDs");
|
|
||||||
}
|
|
||||||
|
|
||||||
const households = await householdModel.reorderUserHouseholds(req.user.id, householdIds);
|
|
||||||
|
|
||||||
if (!households) {
|
|
||||||
return sendError(res, 400, "Household order must include every household you belong to");
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
message: "Household order updated successfully",
|
|
||||||
households,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError(req, "households.reorderHouseholds", error);
|
|
||||||
sendError(res, 500, "Failed to update household order");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get household details
|
// Get household details
|
||||||
exports.getHousehold = async (req, res) => {
|
exports.getHousehold = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
const pool = require("../db/pool");
|
const pool = require("../db/pool");
|
||||||
|
|
||||||
async function queryUserHouseholds(db, userId) {
|
// Get all households a user belongs to
|
||||||
const result = await db.query(
|
exports.getUserHouseholds = async (userId) => {
|
||||||
|
const result = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
h.id,
|
h.id,
|
||||||
h.name,
|
h.name,
|
||||||
@ -9,65 +10,14 @@ async function queryUserHouseholds(db, userId) {
|
|||||||
h.created_at,
|
h.created_at,
|
||||||
hm.role,
|
hm.role,
|
||||||
hm.joined_at,
|
hm.joined_at,
|
||||||
hm.household_sort_order,
|
|
||||||
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
|
||||||
FROM households h
|
FROM households h
|
||||||
JOIN household_members hm ON h.id = hm.household_id
|
JOIN household_members hm ON h.id = hm.household_id
|
||||||
WHERE hm.user_id = $1
|
WHERE hm.user_id = $1
|
||||||
ORDER BY hm.household_sort_order ASC NULLS LAST, hm.joined_at DESC`,
|
ORDER BY hm.joined_at DESC`,
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
|
||||||
|
|
||||||
// Get all households a user belongs to
|
|
||||||
exports.getUserHouseholds = async (userId) => {
|
|
||||||
return queryUserHouseholds(pool, userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.reorderUserHouseholds = async (userId, householdIds) => {
|
|
||||||
const client = await pool.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.query("BEGIN");
|
|
||||||
|
|
||||||
const membershipResult = await client.query(
|
|
||||||
`SELECT household_id
|
|
||||||
FROM household_members
|
|
||||||
WHERE user_id = $1`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentIds = membershipResult.rows.map((row) => Number(row.household_id));
|
|
||||||
const currentIdSet = new Set(currentIds);
|
|
||||||
|
|
||||||
if (
|
|
||||||
householdIds.length !== currentIds.length ||
|
|
||||||
householdIds.some((householdId) => !currentIdSet.has(householdId))
|
|
||||||
) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [index, householdId] of householdIds.entries()) {
|
|
||||||
await client.query(
|
|
||||||
`UPDATE household_members
|
|
||||||
SET household_sort_order = $1
|
|
||||||
WHERE user_id = $2
|
|
||||||
AND household_id = $3`,
|
|
||||||
[index, userId, householdId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const households = await queryUserHouseholds(client, userId);
|
|
||||||
await client.query("COMMIT");
|
|
||||||
return households;
|
|
||||||
} catch (error) {
|
|
||||||
await client.query("ROLLBACK");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get household by ID (with member check)
|
// Get household by ID (with member check)
|
||||||
|
|||||||
@ -16,7 +16,6 @@ const { upload, processImage } = require("../middleware/image");
|
|||||||
// Public routes (authenticated only)
|
// Public routes (authenticated only)
|
||||||
router.get("/", auth, controller.getUserHouseholds);
|
router.get("/", auth, controller.getUserHouseholds);
|
||||||
router.post("/", auth, controller.createHousehold);
|
router.post("/", auth, controller.createHousehold);
|
||||||
router.patch("/order", auth, controller.reorderHouseholds);
|
|
||||||
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
router.post("/join/:inviteCode", auth, controller.joinHousehold);
|
||||||
|
|
||||||
// Household-scoped routes (member access required)
|
// Household-scoped routes (member access required)
|
||||||
|
|||||||
@ -43,7 +43,6 @@ jest.mock("../controllers/households.controller", () => ({
|
|||||||
joinHousehold: jest.fn(),
|
joinHousehold: jest.fn(),
|
||||||
refreshInviteCode: jest.fn(),
|
refreshInviteCode: jest.fn(),
|
||||||
removeMember: jest.fn(),
|
removeMember: jest.fn(),
|
||||||
reorderHouseholds: jest.fn(),
|
|
||||||
updateHousehold: jest.fn(),
|
updateHousehold: jest.fn(),
|
||||||
updateMemberRole: jest.fn(),
|
updateMemberRole: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
jest.mock("../db/pool", () => ({
|
|
||||||
connect: jest.fn(),
|
|
||||||
query: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const pool = require("../db/pool");
|
|
||||||
const Household = require("../models/household.model");
|
|
||||||
|
|
||||||
describe("household.model household ordering", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("loads households using the user's saved sort order", async () => {
|
|
||||||
pool.query.mockResolvedValueOnce({
|
|
||||||
rows: [{ id: 2, name: "Second", household_sort_order: 0 }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const households = await Household.getUserHouseholds(9);
|
|
||||||
|
|
||||||
expect(households).toEqual([{ id: 2, name: "Second", household_sort_order: 0 }]);
|
|
||||||
expect(pool.query).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining("ORDER BY hm.household_sort_order ASC NULLS LAST"),
|
|
||||||
[9]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("persists a full household order for the current user", async () => {
|
|
||||||
const client = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({})
|
|
||||||
.mockResolvedValueOnce({ rows: [{ household_id: 1 }, { household_id: 2 }] })
|
|
||||||
.mockResolvedValueOnce({})
|
|
||||||
.mockResolvedValueOnce({})
|
|
||||||
.mockResolvedValueOnce({ rows: [{ id: 2 }, { id: 1 }] })
|
|
||||||
.mockResolvedValueOnce({}),
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
pool.connect.mockResolvedValueOnce(client);
|
|
||||||
|
|
||||||
const households = await Household.reorderUserHouseholds(9, [2, 1]);
|
|
||||||
|
|
||||||
expect(households).toEqual([{ id: 2 }, { id: 1 }]);
|
|
||||||
expect(client.query).toHaveBeenNthCalledWith(1, "BEGIN");
|
|
||||||
expect(client.query).toHaveBeenNthCalledWith(
|
|
||||||
3,
|
|
||||||
expect.stringContaining("SET household_sort_order = $1"),
|
|
||||||
[0, 9, 2]
|
|
||||||
);
|
|
||||||
expect(client.query).toHaveBeenNthCalledWith(
|
|
||||||
4,
|
|
||||||
expect.stringContaining("SET household_sort_order = $1"),
|
|
||||||
[1, 9, 1]
|
|
||||||
);
|
|
||||||
expect(client.query).toHaveBeenLastCalledWith("COMMIT");
|
|
||||||
expect(client.release).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects an order that does not match the user's memberships", async () => {
|
|
||||||
const client = {
|
|
||||||
query: jest.fn()
|
|
||||||
.mockResolvedValueOnce({})
|
|
||||||
.mockResolvedValueOnce({ rows: [{ household_id: 1 }] })
|
|
||||||
.mockResolvedValueOnce({}),
|
|
||||||
release: jest.fn(),
|
|
||||||
};
|
|
||||||
pool.connect.mockResolvedValueOnce(client);
|
|
||||||
|
|
||||||
const households = await Household.reorderUserHouseholds(9, [1, 2]);
|
|
||||||
|
|
||||||
expect(households).toBeNull();
|
|
||||||
expect(client.query).toHaveBeenNthCalledWith(3, "ROLLBACK");
|
|
||||||
expect(client.release).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
jest.mock("../models/household.model", () => ({
|
jest.mock("../models/household.model", () => ({
|
||||||
getUserRole: jest.fn(),
|
getUserRole: jest.fn(),
|
||||||
reorderUserHouseholds: jest.fn(),
|
|
||||||
transferOwnership: jest.fn(),
|
transferOwnership: jest.fn(),
|
||||||
updateMemberRole: jest.fn(),
|
updateMemberRole: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@ -87,72 +86,3 @@ describe("households.controller updateMemberRole", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("households.controller reorderHouseholds", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
householdModel.reorderUserHouseholds.mockResolvedValue([
|
|
||||||
{ id: 3, name: "Third" },
|
|
||||||
{ id: 1, name: "First" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates the current user's household order", async () => {
|
|
||||||
const req = {
|
|
||||||
body: { household_ids: [3, 1] },
|
|
||||||
user: { id: 9 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.reorderHouseholds(req, res);
|
|
||||||
|
|
||||||
expect(householdModel.reorderUserHouseholds).toHaveBeenCalledWith(9, [3, 1]);
|
|
||||||
expect(res.json).toHaveBeenCalledWith({
|
|
||||||
message: "Household order updated successfully",
|
|
||||||
households: [
|
|
||||||
{ id: 3, name: "Third" },
|
|
||||||
{ id: 1, name: "First" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects duplicate household IDs", async () => {
|
|
||||||
const req = {
|
|
||||||
body: { household_ids: [3, 3] },
|
|
||||||
user: { id: 9 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.reorderHouseholds(req, res);
|
|
||||||
|
|
||||||
expect(householdModel.reorderUserHouseholds).not.toHaveBeenCalled();
|
|
||||||
expect(res.status).toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "household_ids must contain unique positive household IDs",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects orders that do not match current memberships", async () => {
|
|
||||||
householdModel.reorderUserHouseholds.mockResolvedValue(null);
|
|
||||||
const req = {
|
|
||||||
body: { household_ids: [999] },
|
|
||||||
user: { id: 9 },
|
|
||||||
};
|
|
||||||
const res = createResponse();
|
|
||||||
|
|
||||||
await controller.reorderHouseholds(req, res);
|
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(400);
|
|
||||||
expect(res.json).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
error: expect.objectContaining({
|
|
||||||
message: "Household order must include every household you belong to",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -43,7 +43,6 @@ jest.mock("../controllers/households.controller", () => ({
|
|||||||
joinHousehold: jest.fn(),
|
joinHousehold: jest.fn(),
|
||||||
refreshInviteCode: jest.fn(),
|
refreshInviteCode: jest.fn(),
|
||||||
removeMember: jest.fn(),
|
removeMember: jest.fn(),
|
||||||
reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })),
|
|
||||||
updateHousehold: jest.fn(),
|
updateHousehold: jest.fn(),
|
||||||
updateMemberRole: jest.fn(),
|
updateMemberRole: jest.fn(),
|
||||||
}));
|
}));
|
||||||
@ -88,7 +87,6 @@ jest.mock("../controllers/stores.controller", () => ({
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const request = require("supertest");
|
const request = require("supertest");
|
||||||
const router = require("../routes/households.routes");
|
const router = require("../routes/households.routes");
|
||||||
const householdsController = require("../controllers/households.controller");
|
|
||||||
const storesController = require("../controllers/stores.controller");
|
const storesController = require("../controllers/stores.controller");
|
||||||
|
|
||||||
describe("store location routes", () => {
|
describe("store location routes", () => {
|
||||||
@ -108,15 +106,6 @@ describe("store location routes", () => {
|
|||||||
expect(storesController.getHouseholdStores).toHaveBeenCalled();
|
expect(storesController.getHouseholdStores).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("users can reorder their household switcher list", async () => {
|
|
||||||
const response = await request(app)
|
|
||||||
.patch("/households/order")
|
|
||||||
.send({ household_ids: [3, 1, 2] });
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(householdsController.reorderHouseholds).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("members cannot create household stores", async () => {
|
test("members cannot create household stores", async () => {
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.post("/households/1/stores")
|
.post("/households/1/stores")
|
||||||
|
|||||||
@ -28,21 +28,6 @@ The active grocery flow is scoped by household-owned store locations, not the le
|
|||||||
|
|
||||||
Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details.
|
Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details.
|
||||||
|
|
||||||
### Household Switcher Order
|
|
||||||
|
|
||||||
The current user can persist their own household switcher order:
|
|
||||||
|
|
||||||
- Update household order: `PATCH /households/order`
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"household_ids": [3, 1, 2]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The list must contain each household the current user belongs to exactly once. The response returns the reordered household list.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|||||||
@ -5,12 +5,6 @@ import api from "./axios";
|
|||||||
*/
|
*/
|
||||||
export const getUserHouseholds = () => api.get("/households");
|
export const getUserHouseholds = () => api.get("/households");
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the current user's household switcher order
|
|
||||||
*/
|
|
||||||
export const reorderHouseholds = (householdIds) =>
|
|
||||||
api.patch("/households/order", { household_ids: householdIds });
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get details of a specific household
|
* Get details of a specific household
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,12 +3,14 @@ export default function ListSearchInput({ value, onChange, resultCount, totalCou
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glist-search">
|
<div className="glist-search">
|
||||||
|
<label className="glist-search-label" htmlFor="grocery-list-search">
|
||||||
|
Search list
|
||||||
|
</label>
|
||||||
<div className="glist-search-row">
|
<div className="glist-search-row">
|
||||||
<input
|
<input
|
||||||
id="grocery-list-search"
|
id="grocery-list-search"
|
||||||
className="glist-search-input"
|
className="glist-search-input"
|
||||||
type="search"
|
type="search"
|
||||||
aria-label="Search list"
|
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(event.target.value)}
|
onChange={(event) => onChange(event.target.value)}
|
||||||
placeholder="Search list"
|
placeholder="Search list"
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
import useActionToast from "../../hooks/useActionToast";
|
|
||||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
|
||||||
import "../../styles/components/HouseholdSwitcher.css";
|
import "../../styles/components/HouseholdSwitcher.css";
|
||||||
import CreateJoinHousehold from "../manage/CreateJoinHousehold";
|
import CreateJoinHousehold from "../manage/CreateJoinHousehold";
|
||||||
|
|
||||||
@ -10,13 +8,10 @@ export default function HouseholdSwitcher() {
|
|||||||
households,
|
households,
|
||||||
activeHousehold,
|
activeHousehold,
|
||||||
setActiveHousehold,
|
setActiveHousehold,
|
||||||
reorderHouseholds,
|
|
||||||
loading,
|
loading,
|
||||||
hasLoaded,
|
hasLoaded,
|
||||||
} = useContext(HouseholdContext);
|
} = useContext(HouseholdContext);
|
||||||
const toast = useActionToast();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isReordering, setIsReordering] = useState(false);
|
|
||||||
const [showCreateJoin, setShowCreateJoin] = useState(false);
|
const [showCreateJoin, setShowCreateJoin] = useState(false);
|
||||||
|
|
||||||
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
|
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
|
||||||
@ -54,28 +49,6 @@ export default function HouseholdSwitcher() {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMove = async (household, currentIndex, direction) => {
|
|
||||||
const nextIndex = currentIndex + direction;
|
|
||||||
if (nextIndex < 0 || nextIndex >= households.length || isReordering) return;
|
|
||||||
|
|
||||||
const nextHouseholds = [...households];
|
|
||||||
[nextHouseholds[currentIndex], nextHouseholds[nextIndex]] = [
|
|
||||||
nextHouseholds[nextIndex],
|
|
||||||
nextHouseholds[currentIndex],
|
|
||||||
];
|
|
||||||
|
|
||||||
setIsReordering(true);
|
|
||||||
try {
|
|
||||||
await reorderHouseholds(nextHouseholds.map((nextHousehold) => nextHousehold.id));
|
|
||||||
toast.success("Updated household order", `Moved ${household.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getApiErrorMessage(error, "Failed to update household order");
|
|
||||||
toast.error("Household order failed", `Household order failed: ${message}`);
|
|
||||||
} finally {
|
|
||||||
setIsReordering(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="household-switcher">
|
<div className="household-switcher">
|
||||||
<button
|
<button
|
||||||
@ -92,48 +65,18 @@ export default function HouseholdSwitcher() {
|
|||||||
<>
|
<>
|
||||||
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
|
||||||
<div className="household-switcher-dropdown">
|
<div className="household-switcher-dropdown">
|
||||||
{households.map((household, index) => (
|
{households.map((household) => (
|
||||||
<div
|
|
||||||
key={household.id}
|
|
||||||
className={
|
|
||||||
`household-option-row ${household.id === activeHousehold.id ? "active" : ""}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
className="household-option"
|
key={household.id}
|
||||||
|
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelect(household)}
|
onClick={() => handleSelect(household)}
|
||||||
>
|
>
|
||||||
<span className="household-option-name">{household.name}</span>
|
{household.name}
|
||||||
</button>
|
{household.id === activeHousehold.id && (
|
||||||
{households.length > 1 && (
|
<span className="check-mark">✓</span>
|
||||||
<div
|
|
||||||
className="household-reorder-controls"
|
|
||||||
aria-label={`Reorder ${household.name}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="household-reorder-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleMove(household, index, -1)}
|
|
||||||
disabled={index === 0 || isReordering}
|
|
||||||
aria-label={`Move ${household.name} up`}
|
|
||||||
title={`Move ${household.name} up`}
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="household-reorder-button"
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleMove(household, index, 1)}
|
|
||||||
disabled={index === households.length - 1 || isReordering}
|
|
||||||
aria-label={`Move ${household.name} down`}
|
|
||||||
title={`Move ${household.name} down`}
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className="household-divider"></div>
|
<div className="household-divider"></div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import {
|
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households';
|
||||||
createHousehold as createHouseholdApi,
|
|
||||||
getUserHouseholds,
|
|
||||||
reorderHouseholds as reorderHouseholdsApi,
|
|
||||||
} from '../api/households';
|
|
||||||
import { isTransientApiError } from '../api/offlineCache';
|
import { isTransientApiError } from '../api/offlineCache';
|
||||||
import { AuthContext } from './AuthContext';
|
import { AuthContext } from './AuthContext';
|
||||||
|
|
||||||
@ -18,7 +14,6 @@ export const HouseholdContext = createContext({
|
|||||||
setActiveHousehold: () => { },
|
setActiveHousehold: () => { },
|
||||||
refreshHouseholds: () => { },
|
refreshHouseholds: () => { },
|
||||||
createHousehold: () => { },
|
createHousehold: () => { },
|
||||||
reorderHouseholds: () => { },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const HouseholdProvider = ({ children }) => {
|
export const HouseholdProvider = ({ children }) => {
|
||||||
@ -126,57 +121,6 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderHouseholds = async (orderedHouseholdIds) => {
|
|
||||||
const householdById = new Map(
|
|
||||||
households.map((household) => [String(household.id), household])
|
|
||||||
);
|
|
||||||
const nextHouseholds = orderedHouseholdIds
|
|
||||||
.map((householdId) => householdById.get(String(householdId)))
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (nextHouseholds.length !== households.length) {
|
|
||||||
throw new Error("Household order is out of date");
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousHouseholds = households;
|
|
||||||
setHouseholds(nextHouseholds);
|
|
||||||
if (activeHousehold) {
|
|
||||||
const nextActiveHousehold = nextHouseholds.find(
|
|
||||||
(household) => String(household.id) === String(activeHousehold.id)
|
|
||||||
);
|
|
||||||
if (nextActiveHousehold) {
|
|
||||||
setActiveHouseholdState(nextActiveHousehold);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await reorderHouseholdsApi(orderedHouseholdIds);
|
|
||||||
const savedHouseholds = Array.isArray(response.data.households)
|
|
||||||
? response.data.households
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (savedHouseholds.length > 0) {
|
|
||||||
setHouseholds(savedHouseholds);
|
|
||||||
if (activeHousehold) {
|
|
||||||
const savedActiveHousehold = savedHouseholds.find(
|
|
||||||
(household) => String(household.id) === String(activeHousehold.id)
|
|
||||||
);
|
|
||||||
if (savedActiveHousehold) {
|
|
||||||
setActiveHouseholdState(savedActiveHousehold);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return savedHouseholds;
|
|
||||||
} catch (err) {
|
|
||||||
setHouseholds(previousHouseholds);
|
|
||||||
if (activeHousehold) {
|
|
||||||
setActiveHouseholdState(activeHousehold);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
households,
|
households,
|
||||||
activeHousehold,
|
activeHousehold,
|
||||||
@ -186,7 +130,6 @@ export const HouseholdProvider = ({ children }) => {
|
|||||||
setActiveHousehold,
|
setActiveHousehold,
|
||||||
refreshHouseholds: loadHouseholds,
|
refreshHouseholds: loadHouseholds,
|
||||||
createHousehold,
|
createHousehold,
|
||||||
reorderHouseholds,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -70,10 +70,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 0.5rem);
|
top: calc(100% + 0.5rem);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: auto;
|
right: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 100%;
|
|
||||||
max-width: min(320px, calc(100vw - 2rem));
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
@ -82,32 +80,15 @@
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
min-height: 48px;
|
|
||||||
background: var(--card-bg);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-option-row:hover {
|
|
||||||
background: var(--button-secondary-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-option-row.active {
|
|
||||||
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-option {
|
.household-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 0.625rem 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
background: transparent;
|
background: var(--card-bg);
|
||||||
border: none;
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@ -115,59 +96,25 @@
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option-name {
|
.household-option:last-child {
|
||||||
min-width: 0;
|
border-bottom: none;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option:hover {
|
.household-option:hover {
|
||||||
color: var(--primary);
|
background: var(--button-secondary-bg);
|
||||||
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-option-row.active .household-option {
|
.household-option.active {
|
||||||
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-reorder-controls {
|
.check-mark {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.125rem;
|
|
||||||
width: 28px;
|
|
||||||
padding: 0.25rem 0.25rem 0.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-reorder-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 18px;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.55rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.household-reorder-button:hover:not(:disabled),
|
|
||||||
.household-reorder-button:focus-visible {
|
|
||||||
border-color: var(--primary);
|
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
outline: none;
|
font-size: 1.1rem;
|
||||||
}
|
font-weight: bold;
|
||||||
|
|
||||||
.household-reorder-button:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.household-divider {
|
.household-divider {
|
||||||
@ -177,7 +124,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-household-btn {
|
.create-household-btn {
|
||||||
border-bottom: none;
|
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,14 +109,14 @@
|
|||||||
|
|
||||||
/* Classification Groups */
|
/* Classification Groups */
|
||||||
.glist-classification-group {
|
.glist-classification-group {
|
||||||
margin-bottom: 0;
|
margin-bottom: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-classification-header {
|
.glist-classification-header {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
margin: var(--spacing-md) 0 0 0;
|
margin: var(--spacing-md) 0 var(--spacing-sm) 0;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
border-left: var(--border-width-thick) solid var(--color-primary);
|
border-left: var(--border-width-thick) solid var(--color-primary);
|
||||||
@ -232,10 +232,6 @@
|
|||||||
margin-top: var(--spacing-md);
|
margin-top: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-classification-group .glist-ul {
|
|
||||||
margin-top: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-li {
|
.glist-li {
|
||||||
background: var(--color-bg-surface);
|
background: var(--color-bg-surface);
|
||||||
border: var(--border-width-thin) solid var(--color-border-light);
|
border: var(--border-width-thin) solid var(--color-border-light);
|
||||||
@ -251,14 +247,6 @@
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.glist-classification-group .glist-li {
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-classification-group .glist-li:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glist-item-layout {
|
.glist-item-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
@ -370,6 +358,14 @@
|
|||||||
margin: var(--spacing-xs) 0 var(--spacing-sm);
|
margin: var(--spacing-xs) 0 var(--spacing-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glist-search-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.glist-search-row {
|
.glist-search-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -33,8 +33,8 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
];
|
];
|
||||||
|
|
||||||
const storesByHousehold = {
|
const storesByHousehold = {
|
||||||
1: [{ id: 101, household_store_id: 1001, name: "Costco", is_default: true }],
|
1: [{ id: 101, name: "Costco", is_default: true }],
|
||||||
2: [{ id: 201, household_store_id: 2001, name: "Trader Joe's", is_default: true }],
|
2: [{ id: 201, name: "Trader Joe's", is_default: true }],
|
||||||
};
|
};
|
||||||
|
|
||||||
await page.route("**/households", async (route) => {
|
await page.route("**/households", async (route) => {
|
||||||
@ -45,8 +45,8 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/*/stores", async (route) => {
|
await page.route("**/stores/household/*", async (route) => {
|
||||||
const householdId = Number(route.request().url().match(/households\/(\d+)\/stores/)?.[1]);
|
const householdId = Number(route.request().url().split("/").pop());
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -56,15 +56,7 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/*/locations/*/zones", async (route) => {
|
await page.route("**/households/*/stores/*/list/recent", async (route) => {
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({ zones: [] }),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.route("**/households/*/locations/*/list/recent", async (route) => {
|
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -72,7 +64,7 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.route("**/households/*/locations/*/list", async (route) => {
|
await page.route("**/households/*/stores/*/list", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
@ -92,20 +84,8 @@ test("selected household stays active after refreshing on settings and home page
|
|||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Alpha Home" })).toBeVisible();
|
||||||
|
|
||||||
const householdTrigger = page.locator(".household-switcher-toggle");
|
await page.getByRole("button", { name: "Alpha Home" }).click();
|
||||||
await expect(householdTrigger).toContainText("Alpha Home");
|
await page.getByRole("button", { name: "Bravo Home" }).click();
|
||||||
await householdTrigger.click();
|
|
||||||
const householdDropdown = page.locator(".household-switcher-dropdown");
|
|
||||||
await expect(householdDropdown).toBeVisible();
|
|
||||||
await expect(page.locator(".check-mark")).toHaveCount(0);
|
|
||||||
|
|
||||||
const triggerBox = await householdTrigger.boundingBox();
|
|
||||||
const dropdownBox = await householdDropdown.boundingBox();
|
|
||||||
expect(triggerBox).not.toBeNull();
|
|
||||||
expect(dropdownBox).not.toBeNull();
|
|
||||||
expect(Math.abs((dropdownBox?.x ?? 0) - (triggerBox?.x ?? 0))).toBeLessThan(1);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Bravo Home", exact: true }).click();
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "Bravo Home" })).toBeVisible();
|
||||||
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");
|
await expect.poll(() => page.evaluate(() => localStorage.getItem("activeHouseholdId"))).toBe("2");
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
BEGIN;
|
|
||||||
|
|
||||||
ALTER TABLE household_members
|
|
||||||
ADD COLUMN IF NOT EXISTS household_sort_order INTEGER;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_household_members_user_sort_order
|
|
||||||
ON household_members(user_id, household_sort_order, joined_at DESC);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
Loading…
Reference in New Issue
Block a user