nicholas/storage-manager#1: rewrite 'move' feature; style tweaks

This commit is contained in:
2025-04-11 17:09:22 -05:00
parent eb66287e6e
commit 3e9e7c2f43
8 changed files with 286 additions and 127 deletions
+87 -35
View File
@@ -1,16 +1,60 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useReducer } from "react";
import { getAllBoxes, Box, BoxCard, BoxFormModal } from "./features/box";
import { Item, moveItem } from "./features/item";
import { Plus, Search } from "./icons";
const App = () => {
const [boxes, setBoxes] = useState<Box[]>([]);
const [filteredBoxes, setFilteredBoxes] = useState<Box[]>([]);
const [isItemMoving, setIsItemMoving] = useState(false);
const [itemToBeMoved, setItemToBeMoved] = useState<Item>();
const [itemToBeMovedSourceBox, setItemToBeMovedSourceBox] = useState<string>();
const handleSelectMoveToBox = async (targetBox: Box) => {
setIsItemMoving(false);
await moveItem(itemToBeMoved?._id!, targetBox._id!);
setBoxes(prev => {
const updatedBoxes = prev.map(box => {
if (box._id === itemToBeMovedSourceBox) {
return {
...box,
items: box.items?.filter(item => item._id !== itemToBeMoved?._id) ?? []
};
}
if (box._id === targetBox._id && itemToBeMoved) {
return {
...box,
items: [...(box.items ?? []), itemToBeMoved]
};
}
return box;
});
return updatedBoxes;
});
}
const handleMoveItem = (item: Item, boxId: string) => {
setItemToBeMoved(item);
setItemToBeMovedSourceBox(boxId);
setIsItemMoving(true);
}
const handleCreateBox = (newBox: Box) => {
setBoxes((boxes) => [...boxes, newBox]);
};
const handleDeleteBox = (id: string) => {
setBoxes((boxes) => boxes.filter((box) => box._id !== id));
};
const [isBoxFormModalOpen, setBoxFormModalOpen] = useState(false);
const handleBoxFormModalOpen = () => {
setBoxFormModalOpen(true);
@@ -24,21 +68,26 @@ const App = () => {
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearch(event.target.value);
};
const filteredBoxes = boxes.filter((box) => {
useEffect(() => {
const ss = searchString.toLowerCase();
const result = boxes.filter((box) => {
const matchingItems = box.items?.filter(
(item) =>
item.name.toLowerCase().includes(ss) ||
item.description.toLowerCase().includes(ss),
);
const matchingItems = box.items?.filter(
(item) =>
item.name && item.name.toLowerCase().includes(ss) ||
item.description && item.description.toLowerCase().includes(ss),
);
return (
box.name.toLowerCase().includes(ss) ||
box.description.toLowerCase().includes(ss) ||
(matchingItems && matchingItems.length > 0)
);
});
return (
box.name.toLowerCase().includes(ss) ||
box.description.toLowerCase().includes(ss) ||
(matchingItems && matchingItems.length > 0)
);
})
setFilteredBoxes(result);
}, [boxes, searchString]);
useEffect(() => {
const fetchData = async () => {
@@ -65,37 +114,40 @@ const App = () => {
/>
<div className="utility-container">
<button onClick={handleBoxFormModalOpen} className="group button-icon">
{<Plus height="32" width="32" />}
<button onClick={handleBoxFormModalOpen} className="group button-icon">
{<Plus height="36" width="36" />}
</button>
<div className="flex justify-center p-2 align-middle">
<div className="search-container group">
<input
onChange={handleSearchChange}
spellCheck="false"
type="text"
id="box-filter"
name="filter"
className="input-search"
/>
<Search viewBox="0 0 24 24" className="search-icon" />
<div className="flex justify-center p-2 align-middle max-w-lg w-full">
<div className="search-container group">
<input
onChange={handleSearchChange}
spellCheck="false"
type="text"
id="box-filter"
name="filter"
className="input-search"
/>
<Search viewBox="0 0 24 24" className="search-icon" />
</div>
</div>
</div>
</div>
<div className="box-card-list-container">
<ul className="box-card-list">
{filteredBoxes.map((item) => (
<div className="box-card-list">
{filteredBoxes.map((box) => (
<BoxCard
key={item._id}
initialBox={item}
onDelete={handleDeleteBox}
key={box._id}
initialBox={box}
onDeleteBox={handleDeleteBox}
onMoveItem={handleMoveItem}
onClickMoveToBox={handleSelectMoveToBox}
isItemMoving={box._id !== itemToBeMovedSourceBox && isItemMoving}
/>
))}
</ul>
</div>
</div>
</>
);
+3 -3
View File
@@ -47,7 +47,7 @@ export const GenericMenu = ({
}, []);
return (
<div className="relative">
<div className="menu">
<button
className="group button-icon"
ref={menuButtonRef}
@@ -56,8 +56,8 @@ export const GenericMenu = ({
<Icon {...iconProps} />
</button>
{isOpen && (
<div ref={menuRef} className="menu-container">
<ul className={`menu`}>
<div ref={menuRef} className="menu-list-container">
<ul className={`menu-list`}>
{menuItems.map((item, index) => (
<li className={`menu-item`} key={index}>
<button onClick={item.onClick} className={`menu-button`}>
+59 -39
View File
@@ -6,11 +6,13 @@ import { GenericMenu } from '../../../components/GenericMenu';
interface BoxCardProps {
initialBox: Box;
onDelete: (id: string) => void;
onDeleteBox: (id: string) => void;
onMoveItem: (item: Item, boxId: string) => void;
isItemMoving: boolean;
onClickMoveToBox: (box: Box) => void;
}
export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDelete }) => {
const [items, setItems] = useState<Item[]>(initialBox.items!);
export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDeleteBox, onMoveItem, onClickMoveToBox, isItemMoving }) => {
const [box, setBox] = useState<Box>(initialBox);
const [isBoxFormModalOpen, setBoxFormModalOpen] = useState(false);
@@ -18,6 +20,10 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDelete }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
setBox(initialBox);
}, [initialBox]);
const toggleMenu = () => {
setIsMenuOpen((prevState) => !prevState);
};
@@ -37,7 +43,11 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDelete }) => {
};
const handleCreateItem = (newItem: Item) => {
setItems((items) => [...items, newItem]);
const updatedBox: Box = {
...box,
items: [...(box.items ?? []), newItem],
};
setBox(updatedBox);
};
const handleClickEditBox = () => {
@@ -50,16 +60,22 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDelete }) => {
if (box._id != null) {
const response = await deleteBox(box._id);
if (response.success) {
onDelete(box._id);
onDeleteBox(box._id);
} else {
console.error("No data returned in the response");
}
}
};
const handleDeleteItem = (id: string) => {
setItems((items) => items.filter((item) => item._id !== id));
const updatedItems = (box.items ?? []).filter(item => item._id !== id);
const updatedBox: Box = {
...box,
items: updatedItems,
};
setBox(updatedBox);
};
const menuItems = [
{
label: "Edit",
@@ -91,41 +107,45 @@ export const BoxCard: React.FC<BoxCardProps> = ({ initialBox, onDelete }) => {
onClose={handleCloseItemFormModal}
boxId={box._id!}
/>
<li className="box-card">
<div className="box-card-header">
<div className="box-card-title-container">
<h1 className="box-card-title">{box.name}</h1>
<h2 className="box-card-subtitle">{box.description}</h2>
<div className={`box-card-container relative ${isItemMoving ? 'target' : ''}`} >
{isItemMoving && <div onClick={() => onClickMoveToBox(box)} className="overlay"></div>}
<div className={`box-card`}>
<div className="box-card-header">
<div className="box-card-title-container">
<h1 className="box-card-title">{box.name}</h1>
<h2 className="box-card-subtitle">{box.description}</h2>
</div>
<GenericMenu
onToggle={toggleMenu}
menuItems={menuItems}
isOpen={isMenuOpen}
Icon={Ellipsis}
iconProps={{ height: 24, width: 24 }}
/>
</div>
<div className="item-card-list-container scrollbar-minimal">
<ul className="item-card-list">
{box.items?.map((item) => (
<ItemCard
key={item._id}
boxId={box._id!}
initialItem={item}
onDeleteItem={handleDeleteItem}
onMoveItem={onMoveItem}
/>
))}
</ul>
</div>
<div className="actions">
<button
className="group button-icon"
onClick={handleOpenItemFormModal}
>
<Plus height="24" width="24" />
</button>
</div>
<GenericMenu
onToggle={toggleMenu}
menuItems={menuItems}
isOpen={isMenuOpen}
Icon={Ellipsis}
iconProps={{ height: 24, width: 24 }}
/>
</div>
<div className="item-card-list-container">
<ul className="item-card-list">
{items?.map((item) => (
<ItemCard
key={item._id}
boxId={box._id!}
initialItem={item}
onDelete={handleDeleteItem}
/>
))}
</ul>
</div>
<div>
<button
className="group button-icon"
onClick={handleOpenItemFormModal}
>
<Plus height="24" width="24" />
</button>
</div>
</li>
</div >
</>
);
};
@@ -6,17 +6,22 @@ import { GenericMenu } from "../../../components/GenericMenu";
interface ItemCardProps {
boxId: string;
initialItem: Item;
onDelete: (id: string) => void;
onDeleteItem: (id: string) => void;
onMoveItem: (item: Item, boxId: string) => void;
}
export const ItemCard: React.FC<ItemCardProps> = ({
boxId,
initialItem,
onDelete,
onDeleteItem,
onMoveItem
}) => {
const [isItemFormModalOpen, setIsItemFormModalOpen] = useState(false);
const [item, setItem] = useState(initialItem);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [formModalMode, setFormModalMode] = useState("edit");
const toggleMenu = () => {
setIsMenuOpen((prevState) => !prevState);
};
@@ -24,8 +29,14 @@ export const ItemCard: React.FC<ItemCardProps> = ({
setIsItemFormModalOpen(false);
};
const handleClickEditItem = async () => {
setFormModalMode("edit");
setIsItemFormModalOpen(true);
};
const handleClickViewItem = async () => {
setFormModalMode("readonly");
setIsItemFormModalOpen(true);
}
const handleUpdateItem = (updatedItem: Item) => {
setItem(updatedItem);
};
@@ -34,20 +45,25 @@ export const ItemCard: React.FC<ItemCardProps> = ({
if (item._id != null) {
const response = await deleteItem(boxId, item._id);
if (response.success) {
onDelete(item._id);
onDeleteItem(item._id);
} else {
console.error("No data returned in the response");
}
}
};
const handleClickMoveItem = async () => {
setIsMenuOpen(false);
onMoveItem(item, boxId);
}
const menuItems = [
{ label: "Edit", icon: Pencil, onClick: handleClickEditItem },
{ label: "Delete", icon: Garbage, onClick: handleDeleteItem },
{ label: "Move", icon: Pencil, onClick: handleClickMoveItem }
];
return (
<>
<ItemFormModal
mode="edit"
mode={formModalMode}
isOpen={isItemFormModalOpen}
onSubmitItem={handleUpdateItem}
onClose={handleCloseItemFormModal}
@@ -56,7 +72,9 @@ export const ItemCard: React.FC<ItemCardProps> = ({
/>
<li>
<div className="item-container">
<h3 className="item-name">{item.name}</h3>
<div className='item' onClick={handleClickViewItem}>
<h3 className="item-name">{item.name}</h3>
</div>
<GenericMenu
isOpen={isMenuOpen}
menuItems={menuItems}
@@ -1,5 +1,5 @@
import { useState } from "react";
import {Modal} from '../../../components/Modal';
import { Modal } from '../../../components/Modal';
import { Item, createItem, updateItem } from "..";
interface ItemFormModalProps {
@@ -24,16 +24,22 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
);
const [itemQuantity, setItemQuantity] = useState(initialData?.quantity ?? 0);
const titles: Record<string, string> = {
readonly: 'View',
edit: 'Edit',
create: 'Create',
};
const itemTitle = titles[mode] ?? 'Item';
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const isEdit = mode === "edit";
const itemData: Item = {
name: itemName as string,
description: itemDescription as string,
quantity: itemQuantity as number,
};
const isEdit = mode === 'edit';
try {
const response = isEdit
@@ -60,12 +66,13 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
<Modal isOpen={isOpen}>
<form onSubmit={handleSubmit} id="form">
<input value="" id="id" name="id" readOnly={true} hidden={true} />
<h1>Edit Item</h1>
<h1>{itemTitle} Item</h1>
<fieldset>
<label className="input-label" htmlFor="item-name">
name
</label>
<input
readOnly={mode === "readonly"}
className="input"
value={itemName}
onChange={(e) => setItemName(e.currentTarget.value)}
@@ -78,6 +85,7 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
description
</label>
<input
readOnly={mode === "readonly"}
className="input"
value={itemDescription}
onChange={(e) => setItemDescription(e.currentTarget.value)}
@@ -90,6 +98,7 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
quantity
</label>
<input
readOnly={mode === "readonly"}
className="input"
value={itemQuantity}
onChange={(e) =>
@@ -101,9 +110,9 @@ export const ItemFormModal: React.FC<ItemFormModalProps> = ({
/>
<div>
<button className="button" type="submit">
{mode !== "readonly" && <button className="button" type="submit">
Submit
</button>
</button>}
<button className="button" type="button" onClick={onClose}>
Cancel
</button>
@@ -50,7 +50,8 @@ const deleteItem = async (
return {
success: true,
};
} catch (error) {
}
catch (error) {
console.error("Error deleting item:", error);
return {
@@ -59,4 +60,21 @@ const deleteItem = async (
};
}
};
export { createItem, updateItem, deleteItem };
const moveItem = async (itemId: string, boxId: string): Promise<ApiResponse<null>> => {
try {
const response = await api.patch<null>(`items/${itemId}`, { boxId });
return {
success: true,
};
}
catch (error) {
console.error("Error moving item:", error);
return {
success: false,
error: "Could not move item",
};
}
}
export { createItem, updateItem, deleteItem, moveItem };
+47 -12
View File
@@ -24,16 +24,28 @@ code {
--color-subdued: #b3b3b3;
}
.scrollbar-minimal::-webkit-scrollbar {
@apply h-1 w-1;
}
.scrollbar-minimal::-webkit-scrollbar-thumb {
@apply bg-highlight rounded-full;
}
.scrollbar-minimal::-webkit-scrollbar-track {
@apply bg-transparent;
}
html {
@apply bg-black;
}
.utility-container {
@apply flex py-4 w-full justify-center;
@apply flex py-4 w-full justify-center items-center;
}
.button-icon {
@apply cursor-pointer rounded-full p-2 hover:bg-highlight;
@apply h-min cursor-pointer rounded-full p-2 hover:bg-highlight;
}
.icon {
@apply fill-subdued group-hover:fill-white;
@@ -43,10 +55,16 @@ html {
@apply flex p-4;
}
.box-card-list {
@apply m-0 flex flex-wrap space-x-4 p-0;
@apply m-0 flex flex-wrap space-x-4 p-0 items-start;
}
.box-card-container {
@apply box-border;
}
.box-card {
@apply m-2 max-h-128 min-w-sm rounded-md bg-base p-6 text-subdued;
@apply grid grid-rows-[auto_1fr_auto] m-2 min-h-64 max-h-128 min-w-sm rounded-md bg-base p-4 text-subdued;
}
.overlay {
@apply absolute inset-0 bg-black opacity-50 z-10 border-dashed border-2 border-subdued hover:border-green-300 hover:cursor-pointer;
}
.box-card-header {
@apply flex justify-between;
@@ -57,11 +75,13 @@ html {
.box-card-subtitle {
@apply mb-4 text-sm font-semibold text-subdued;
}
.menu-container {
@apply absolute right-0 z-10 mt-2 w-36 rounded bg-highlight p-1 shadow-[0_16px_24px_rgba(0,0,0,0.3),_0_6px_8px_rgba(0,0,0,0.2)];
}
.menu {
@apply z-50 flex items-center relative;
}
.menu-list-container {
@apply absolute right-0 z-10 mt-2 w-36 rounded bg-highlight p-1 shadow-lg;
}
.menu-list {
@apply text-sm;
}
.menu-button {
@@ -75,10 +95,21 @@ html {
@apply block w-full cursor-pointer text-left;
}
.item-card-list-container {
@apply max-h-64 overflow-y-auto;
}
.item-card-list {
@apply mr-2;
}
.item-container {
@apply flex justify-between p-2 hover:bg-highlight;
@apply flex justify-between align-middle rounded-sm px-2 hover:bg-highlight;
}
.item {
@apply flex flex-auto hover:cursor-pointer p-2;
}
.item-name {
@apply cursor-default;
}
.modal-container {
@apply fixed inset-0 z-20 flex items-center justify-center bg-black/80;
}
@@ -97,13 +128,17 @@ html {
}
.search-container {
@apply relative w-full max-w-sm focus-within:border-white focus-within:fill-white;
@apply relative w-full max-w-lg focus-within:fill-white;
}
.input-search {
@apply box-border w-full rounded-full border-1 border-subdued py-2 pr-4 pl-10 group-focus-within:border-1 group-focus-within:border-white focus:outline-none;
@apply border-2 border-subdued bg-highlight group-hover:bg-highlight-2 w-full rounded-full py-2 pr-4 pl-10 focus:outline-none group-focus-within:border-white;
}
.search-icon {
@apply absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 transform fill-subdued group-focus-within:fill-white;
}
.actions {
@apply mt-4;
}
+28 -21
View File
@@ -184,29 +184,36 @@ app.delete('/boxes/:boxId/items/:itemId', async (request, response) => {
});
//move item
app.post('/box/item/:itemId/:boxIdTarget', async (request, response) => {
const itemID = request.params.itemId;
const targetBoxID = request.params.boxIdTarget;
//the way I am doing this cannot be the best way...fix me later
app.patch('/items/:itemId', async (req, res) => {
const { itemId } = req.params;
const { boxId: targetBoxId } = req.body;
try {
//find source box
const box = await Box.find({ "item._id": itemID });
//get item in source box
const item = await Box.find({ "item._id": itemID }, { item: { $elemMatch: { _id: itemID } } });
//remove item from source box
await Box.updateOne({ _id: box[0]._id }, { $pull: { item: { _id: itemID } } });
//add item to target box
await Box.updateOne({ _id: targetBoxID }, { $push: { item: item[0].item[0] } });
if (!targetBoxId) return res.status(400).json({ error: 'Target boxId is required' });
const boxes = await getAllBoxes();
response.send(boxes);
}
catch (error) {
response.json(error);
}
})
/*------------/CONTROLLER------------*/
const item = await Item.findById(itemId);
if (!item) return res.status(404).json({ error: 'Item not found' });
console.log(item);
const sourceBox = await Box.findOne({ "items._id": item._id });
const targetBox = await Box.findById(targetBoxId);
if (!targetBox) return res.status(404).json({ error: 'Target box not found' });
if (!sourceBox) return res.status(404).json({ error: 'Source box not found' });
await Box.updateOne(
{ _id: sourceBox._id },
{ $pull: { items: item } }
);
await Box.updateOne(
{ _id: targetBoxId },
{ $push: { items: item } }
);
res.status(204).send();
});
/*--------/CONTROLLER------------*/
const getAllBoxes = async (queryParameters) => {
queryParameters = queryParameters ?? {};