fix: buy unmapped map items in place

This commit is contained in:
Nico 2026-06-04 11:11:01 -07:00
parent a92c0a69c2
commit 1658b7f979
5 changed files with 130 additions and 31 deletions

View File

@ -44,6 +44,7 @@ export default function LocationMapBottomSheet({
onDeleteObject, onDeleteObject,
onShowHiddenAreas, onShowHiddenAreas,
onSelectZoneItem, onSelectZoneItem,
onSelectUnmappedItem,
}) { }) {
const selectedTitle = getMapObjectDisplayLabel(selectedObject); const selectedTitle = getMapObjectDisplayLabel(selectedObject);
const selectedZoneId = objectZoneId(selectedObject); const selectedZoneId = objectZoneId(selectedObject);
@ -292,6 +293,7 @@ export default function LocationMapBottomSheet({
hiddenUnmappedItemCount={hiddenUnmappedItemCount} hiddenUnmappedItemCount={hiddenUnmappedItemCount}
hasHiddenUnmappedItems={hasHiddenUnmappedItems} hasHiddenUnmappedItems={hasHiddenUnmappedItems}
onShowUnmappedItems={showUnmappedItems} onShowUnmappedItems={showUnmappedItems}
onSelectUnmappedItem={onSelectUnmappedItem}
/> />
) : null} ) : null}
</aside> </aside>

View File

@ -13,6 +13,27 @@ export const MAP_DISPLAY_CONTROLS = [
const UNMAPPED_PREVIEW_LIMIT = 8; const UNMAPPED_PREVIEW_LIMIT = 8;
function MapItemActionRow({ item, onSelectItem }) {
const isBought = Boolean(item.bought);
return (
<li className={`location-map-item-row ${isBought ? "is-bought" : ""}`}>
<button
type="button"
className="location-map-item-button"
onClick={() => {
if (!isBought) onSelectItem?.(item);
}}
disabled={isBought}
aria-label={isBought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
>
<span>{item.item_name}</span>
<small>{isBought ? "Bought" : `x${item.quantity}`}</small>
</button>
</li>
);
}
export function LocationMapLayerPanel({ filters, setFilters }) { export function LocationMapLayerPanel({ filters, setFilters }) {
return ( return (
<div className="location-map-display-panel"> <div className="location-map-display-panel">
@ -139,20 +160,7 @@ export function SelectedZoneItemsPanel({
<div className="location-map-zone-items"> <div className="location-map-zone-items">
<ul> <ul>
{selectedZoneItems.map((item) => ( {selectedZoneItems.map((item) => (
<li key={item.id} className={item.bought ? "is-bought" : ""}> <MapItemActionRow key={item.id} item={item} onSelectItem={onSelectZoneItem} />
<button
type="button"
className="location-map-zone-item-button"
onClick={() => {
if (!item.bought) onSelectZoneItem?.(item);
}}
disabled={Boolean(item.bought)}
aria-label={item.bought ? `${item.item_name} bought` : `Mark ${item.item_name} bought`}
>
<span>{item.item_name}</span>
<small>{item.bought ? "Bought" : `x${item.quantity}`}</small>
</button>
</li>
))} ))}
</ul> </ul>
{hasHiddenSelectedItems ? ( {hasHiddenSelectedItems ? (
@ -174,6 +182,7 @@ export function UnmappedItemsPanel({
hiddenUnmappedItemCount, hiddenUnmappedItemCount,
hasHiddenUnmappedItems, hasHiddenUnmappedItems,
onShowUnmappedItems, onShowUnmappedItems,
onSelectUnmappedItem,
}) { }) {
if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) { if (visibleUnmappedItems.length === 0 && hasHiddenUnmappedItems) {
return ( return (
@ -203,7 +212,7 @@ export function UnmappedItemsPanel({
<strong>Unmapped Items</strong> <strong>Unmapped Items</strong>
<ul> <ul>
{unmappedItemPreview.map((item) => ( {unmappedItemPreview.map((item) => (
<li key={item.id}>{item.item_name}</li> <MapItemActionRow key={item.id} item={item} onSelectItem={onSelectUnmappedItem} />
))} ))}
{overflowUnmappedItemCount > 0 ? ( {overflowUnmappedItemCount > 0 ? (
<li <li

View File

@ -119,6 +119,10 @@ export default function LocationMapManager() {
() => unmappedItems(mapItems, filters, username), () => unmappedItems(mapItems, filters, username),
[filters, mapItems, username] [filters, mapItems, username]
); );
const buyModalItems = useMemo(
() => (selectedObject ? selectedZoneItems : visibleUnmappedItems),
[selectedObject, selectedZoneItems, visibleUnmappedItems]
);
const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => { const { visibleAssignedItemCount, unmappedItemCount } = useMemo(() => {
let nextVisibleAssignedItemCount = 0; let nextVisibleAssignedItemCount = 0;
let nextUnmappedItemCount = 0; let nextUnmappedItemCount = 0;
@ -166,17 +170,17 @@ export default function LocationMapManager() {
return; return;
} }
const item = selectedZoneItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem; const item = buyModalItems.find((candidate) => candidate.id === buyModalItem.id) || buyModalItem;
try { try {
const currentIndex = selectedZoneItems.findIndex((candidate) => candidate.id === item.id); const currentIndex = buyModalItems.findIndex((candidate) => candidate.id === item.id);
const resolvedIndex = currentIndex >= 0 ? currentIndex : 0; const resolvedIndex = currentIndex >= 0 ? currentIndex : 0;
await markBought(activeHousehold.id, locationId, item.item_name, quantity, true); await markBought(activeHousehold.id, locationId, item.item_name, quantity, true);
if (quantity >= item.quantity) { if (quantity >= item.quantity) {
updateMapItems((currentItems) => currentItems.filter((candidate) => candidate.id !== item.id)); updateMapItems((currentItems) => currentItems.filter((candidate) => candidate.id !== item.id));
setBuyModalItem(getNextMapBuyItem(selectedZoneItems, resolvedIndex, item.id)); setBuyModalItem(getNextMapBuyItem(buyModalItems, resolvedIndex, item.id));
} else { } else {
const response = await getItemByName(activeHousehold.id, locationId, item.item_name); const response = await getItemByName(activeHousehold.id, locationId, item.item_name);
const updatedItem = response.data || { const updatedItem = response.data || {
@ -195,7 +199,7 @@ export default function LocationMapManager() {
const message = getApiErrorMessage(error, "Failed to mark item as bought"); const message = getApiErrorMessage(error, "Failed to mark item as bought");
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`); toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
} }
}, [activeHousehold?.id, buyModalItem, locationId, selectedZoneItems, toast, updateMapItems]); }, [activeHousehold?.id, buyModalItem, buyModalItems, locationId, toast, updateMapItems]);
const mapSize = useMemo( const mapSize = useMemo(
() => ({ () => ({
@ -435,6 +439,7 @@ export default function LocationMapManager() {
onDeleteObject={requestDeleteObject} onDeleteObject={requestDeleteObject}
onShowHiddenAreas={handleShowHiddenAreas} onShowHiddenAreas={handleShowHiddenAreas}
onSelectZoneItem={setBuyModalItem} onSelectZoneItem={setBuyModalItem}
onSelectUnmappedItem={setBuyModalItem}
/> />
</> </>
)} )}
@ -443,7 +448,7 @@ export default function LocationMapManager() {
{buyModalItem ? ( {buyModalItem ? (
<ConfirmBuyModal <ConfirmBuyModal
item={buyModalItem} item={buyModalItem}
allItems={selectedZoneItems} allItems={buyModalItems}
onNavigate={handleMapBuyNavigate} onNavigate={handleMapBuyNavigate}
onCancel={handleMapBuyCancel} onCancel={handleMapBuyCancel}
onConfirm={handleMapBuyConfirm} onConfirm={handleMapBuyConfirm}

View File

@ -752,12 +752,13 @@
color: #f8fafc; color: #f8fafc;
} }
.location-map-zone-items li { .location-map-zone-items li.location-map-item-row,
.location-map-unmapped-list li.location-map-item-row {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
.location-map-zone-item-button { .location-map-item-button {
width: 100%; width: 100%;
min-height: 34px; min-height: 34px;
display: flex; display: flex;
@ -774,40 +775,44 @@
cursor: pointer; cursor: pointer;
} }
.location-map-zone-item-button span { .location-map-item-button span {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.location-map-zone-item-button:hover, .location-map-item-button:hover,
.location-map-zone-item-button:focus-visible { .location-map-item-button:focus-visible {
outline: none; outline: none;
background: rgba(96, 165, 250, 0.16); background: rgba(96, 165, 250, 0.16);
} }
.location-map-zone-items li.is-bought { .location-map-zone-items li.is-bought,
.location-map-unmapped-list li.is-bought {
background: rgba(20, 184, 166, 0.12); background: rgba(20, 184, 166, 0.12);
border-color: rgba(20, 184, 166, 0.3); border-color: rgba(20, 184, 166, 0.3);
} }
.location-map-zone-items li.is-bought .location-map-zone-item-button { .location-map-zone-items li.is-bought .location-map-item-button,
.location-map-unmapped-list li.is-bought .location-map-item-button {
color: #cbd5e1; color: #cbd5e1;
cursor: default; cursor: default;
} }
.location-map-zone-items li.is-bought .location-map-zone-item-button span { .location-map-zone-items li.is-bought .location-map-item-button span,
.location-map-unmapped-list li.is-bought .location-map-item-button span {
text-decoration: line-through; text-decoration: line-through;
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
text-decoration-color: rgba(20, 184, 166, 0.85); text-decoration-color: rgba(20, 184, 166, 0.85);
} }
.location-map-zone-item-button:disabled { .location-map-item-button:disabled {
opacity: 1; opacity: 1;
} }
.location-map-zone-items small { .location-map-zone-items small,
.location-map-unmapped-list small {
color: #cbd5e1; color: #cbd5e1;
font-weight: 800; font-weight: 800;
white-space: nowrap; white-space: nowrap;

View File

@ -2119,7 +2119,7 @@ test("viewer shows a compact overflow cue for long unmapped lists", async ({ pag
await page.goto("/stores/100/locations/10/map"); await page.goto("/stores/100/locations/10/map");
await page.getByRole("button", { name: "Layers" }).click(); await page.getByRole("button", { name: "Layers" }).click();
const unmappedToggle = page.getByRole("button", { name: "Unmapped" }); const unmappedToggle = page.locator(".location-map-display-panel").getByRole("button", { name: "Unmapped" });
await expect(unmappedToggle).toHaveAttribute("aria-pressed", "false"); await expect(unmappedToggle).toHaveAttribute("aria-pressed", "false");
await unmappedToggle.click(); await unmappedToggle.click();
await expect(unmappedToggle).toHaveAttribute("aria-pressed", "true"); await expect(unmappedToggle).toHaveAttribute("aria-pressed", "true");
@ -2130,6 +2130,84 @@ test("viewer shows a compact overflow cue for long unmapped lists", async ({ pag
await expect(page.getByLabel("3 more unmapped items")).toHaveText("+3 more"); await expect(page.getByLabel("3 more unmapped items")).toHaveText("+3 more");
}); });
test("viewer can buy unmapped map items in place", async ({ page }) => {
await mockMapShell(page);
const unmappedMapItems = [
{
id: 4201,
item_name: "loose batteries",
quantity: 1,
bought: false,
zone_id: null,
zone: null,
added_by_users: ["map-user"],
},
{
id: 4202,
item_name: "paper plates",
quantity: 2,
bought: false,
zone_id: null,
zone: null,
added_by_users: ["map-user"],
},
];
const mapState = {
...publishedMapState([], true),
items: unmappedMapItems,
unmapped_count: unmappedMapItems.length,
};
const boughtPayloads: Array<Record<string, unknown>> = [];
await page.route("**/households/1/locations/10/map", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(mapState),
});
});
await page.route("**/households/1/locations/10/list/item", async (route) => {
boughtPayloads.push(await route.request().postDataJSON());
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true }),
});
});
await page.goto("/stores/100/locations/10/map");
await page.getByRole("button", { name: "Layers" }).click();
await page.getByRole("button", { name: "Unmapped" }).click();
await page.getByRole("button", { name: "Mark loose batteries bought" }).click();
await expect(page.locator(".confirm-buy-item-name")).toHaveText("loose batteries");
await page.getByRole("button", { name: "Mark as Bought" }).click();
await expect.poll(() => boughtPayloads.length).toBe(1);
expect(boughtPayloads[0]).toMatchObject({
item_name: "loose batteries",
bought: true,
quantity_bought: 1,
});
await expect(page.locator(".confirm-buy-item-name")).toHaveText("paper plates");
await expect(page.getByText("loose batteries")).toHaveCount(0);
await expect(page.getByLabel("Map summary").locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("1");
await page.getByRole("button", { name: "Mark as Bought" }).click();
await expect.poll(() => boughtPayloads.length).toBe(2);
expect(boughtPayloads[1]).toMatchObject({
item_name: "paper plates",
bought: true,
quantity_bought: 2,
});
await expect(page.locator(".confirm-buy-modal")).toHaveCount(0);
await expect(page.getByText("paper plates")).toHaveCount(0);
await expect(page.getByLabel("Map summary").locator(".location-map-overview-row", { hasText: "Unmapped" })).toContainText("0");
});
test("viewer hides unrelated unmapped items while a zone is selected", async ({ page }) => { test("viewer hides unrelated unmapped items while a zone is selected", async ({ page }) => {
await mockMapShell(page); await mockMapShell(page);