fix: buy unmapped map items in place
This commit is contained in:
parent
a92c0a69c2
commit
1658b7f979
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user