diff --git a/src/components/AddItems.jsx b/src/components/AddItems.jsx index 7b0d01a..dea53de 100644 --- a/src/components/AddItems.jsx +++ b/src/components/AddItems.jsx @@ -1,9 +1,16 @@ import { useCallback } from 'react'; -import { normalizeItemName } from '../utils'; import { useStateWithStorage } from '../hooks'; import { addItem } from '../api'; +import { normalizeItemName } from '../utils'; import { RadioInputElement, TextInputElement } from './index.js'; import { toast } from 'react-toastify'; +import { Box, Button, FormControl, RadioGroup } from '@mui/material'; +import { buttonStyle } from './index'; + +const radioGroupStyle = { + mx: 1, + justifyContent: 'space-between', +}; const daysUntilPurchaseOptions = { Soon: 7, @@ -59,27 +66,43 @@ export function AddItems({ items }) { ); return ( -
-
+ + + + + {Object.entries(daysUntilPurchaseOptions).map(([key, value]) => ( + + ))} + + - {Object.entries(daysUntilPurchaseOptions).map(([key, value]) => ( - - ))} - - -
+ + + ); } diff --git a/src/components/ConfirmDialog.jsx b/src/components/ConfirmDialog.jsx index 69eb804..e9d8941 100644 --- a/src/components/ConfirmDialog.jsx +++ b/src/components/ConfirmDialog.jsx @@ -8,7 +8,7 @@ import { Button, } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; -import { buttonStyle } from './SingleList'; +import { buttonStyle } from './index'; import './ConfirmDialog.css'; // MUI's Dialog already comes with built-in focus management and accessibility features. @@ -23,11 +23,6 @@ const dialogStyle = { backgroundColor: 'rgb(20, 20, 20)', }; -const typographyStyle = { - padding: '1em', - color: 'white', -}; - export function ConfirmDialog({ props }) { const { handleDelete, title, setOpen, open } = props; diff --git a/src/components/DeleteIconWithTooltip.jsx b/src/components/DeleteIconWithTooltip.jsx index 1d46c02..d88249a 100644 --- a/src/components/DeleteIconWithTooltip.jsx +++ b/src/components/DeleteIconWithTooltip.jsx @@ -1,11 +1,6 @@ import { DeleteOutlineOutlined } from '@mui/icons-material'; import { Tooltip, IconButton } from '@mui/material'; - -export const tooltipStyle = { - fontSize: '1.5rem', - marginBlockStart: '0', - marginBlockEnd: '0', -}; +import { tooltipStyle } from './MUIStyles'; export const DeleteIconWithTooltip = ({ ariaLabel, toggleDialog }) => { return ( diff --git a/src/components/ListItem.jsx b/src/components/ListItem.jsx index c1220a0..6660245 100644 --- a/src/components/ListItem.jsx +++ b/src/components/ListItem.jsx @@ -3,8 +3,7 @@ import { updateItem, deleteItem } from '../api'; import { calculateDateNextPurchased, ONE_DAY_IN_MILLISECONDS } from '../utils'; import { toast } from 'react-toastify'; import { useConfirmDialog } from '../hooks/useConfirmDialog'; -import { ConfirmDialog } from './ConfirmDialog'; -import { DeleteIconWithTooltip, tooltipStyle } from './DeleteIconWithTooltip'; +import { DeleteIconWithTooltip, ConfirmDialog, tooltipStyle } from './index'; import { ListItem as MaterialListItem, Tooltip, @@ -29,8 +28,8 @@ const currentDate = new Date(); const urgencyStatusIcons = { overdue: OverdueIcon, soon: SoonIcon, - kindOfSoon: KindOfSoonIcon, - notSoon: NotSoonIcon, + 'kind of soon': KindOfSoonIcon, + 'not soon': NotSoonIcon, inactive: InactiveIcon, }; @@ -39,12 +38,6 @@ const urgencyStatusStyle = { color: 'white', }; -const toolTipStyle = { - fontSize: '1.5rem', - marginBlockStart: '0', - marginBlockEnd: '0', -}; - const calculateIsPurchased = (dateLastPurchased) => { if (!dateLastPurchased) { return false; @@ -86,7 +79,6 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { }; const handleDeleteItem = async () => { - console.log('attempting item deletion'); try { await deleteItem(listPath, id); toast.success('Item deleted'); @@ -102,7 +94,7 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { handleDelete: handleDeleteItem, title: `Are you sure you want to delete ${name}?`, setOpen: isOpen, - open: open, + open, }; const tooltipTitle = isPurchased @@ -115,7 +107,7 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) { {UrgencyStatusIcon && ( {itemUrgencyStatus}

} + title={

{itemUrgencyStatus}

} placement="left" arrow > diff --git a/src/components/MUIStyles.js b/src/components/MUIStyles.js new file mode 100644 index 0000000..0fd879e --- /dev/null +++ b/src/components/MUIStyles.js @@ -0,0 +1,38 @@ +const paperStyle = { + color: 'white', + p: '1rem', +}; + +export const darkPaperStyle = { + ...paperStyle, + paddingBlockStart: '4rem', + paddingBlockEnd: '4rem', + background: `linear-gradient(45deg, rgba(117, 124, 232, 0.2) 0%, rgba(117, 124, 232, 0.05) 100%)`, // Pale blue gradient on top + backdropFilter: 'blur(2px)', +}; + +export const lightPaperStyle = { + ...paperStyle, + background: `rgba(117, 124, 232, 0.2)`, +}; + +export const tooltipStyle = { + fontSize: '1.5rem', + marginBlockStart: '0', + marginBlockEnd: '0', +}; + +export const buttonStyle = { + color: 'white', + fontSize: '1.5rem', +}; + +export const buttonWithTopMarginStyle = { + ...buttonStyle, + marginTop: '0.5rem', +}; + +export const typographyStyle = { + padding: '1em', + color: 'white', +}; diff --git a/src/components/RadioInputElement.jsx b/src/components/RadioInputElement.jsx index 3a38645..7774364 100644 --- a/src/components/RadioInputElement.jsx +++ b/src/components/RadioInputElement.jsx @@ -1,15 +1,33 @@ +import { Radio, Tooltip, FormControlLabel } from '@mui/material'; +import { tooltipStyle } from './MUIStyles'; + +const radioStyle = { + color: 'white', + m: 2, + my: 3, +}; + export const RadioInputElement = ({ label, id, value, required }) => { return ( - <> - - -
- + + } + label={ + {`${value} days from today`}

} + arrow + > + {label} +
+ } + /> ); }; diff --git a/src/components/ShareList.jsx b/src/components/ShareList.jsx index 26f7801..11fc6a9 100644 --- a/src/components/ShareList.jsx +++ b/src/components/ShareList.jsx @@ -1,7 +1,8 @@ -import { shareList } from '../api'; import { useStateWithStorage, useAuth } from '../hooks'; -import { TextInputElement } from './index.js'; +import { shareList } from '../api'; import { toast } from 'react-toastify'; +import { Box, Button } from '@mui/material'; +import { TextInputElement, buttonWithTopMarginStyle } from './index.js'; export function ShareList() { const [listPath] = useStateWithStorage('tcl-shopping-list-path', null); @@ -39,17 +40,29 @@ export function ShareList() { }; return ( -
-
+ + - - -
+ + + ); } diff --git a/src/components/SingleList.css b/src/components/SingleList.css index d6be38f..70c61be 100644 --- a/src/components/SingleList.css +++ b/src/components/SingleList.css @@ -1,6 +1,7 @@ .SingleList { align-items: baseline; display: flex; + width: inherit; flex-direction: row; align-items: center; font-size: 1.2em; diff --git a/src/components/SingleList.jsx b/src/components/SingleList.jsx index f8e8ae7..d6de756 100644 --- a/src/components/SingleList.jsx +++ b/src/components/SingleList.jsx @@ -1,13 +1,18 @@ -import { useNavigate } from 'react-router-dom'; import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { PushPin, PushPinOutlined } from '@mui/icons-material'; -import { Tooltip, IconButton, Button } from '@mui/material'; import { deleteList } from '../api'; -import { useAuth } from '../hooks'; -import { useConfirmDialog } from '../hooks/useConfirmDialog'; -import { ConfirmDialog } from './ConfirmDialog'; -import { tooltipStyle, DeleteIconWithTooltip } from './DeleteIconWithTooltip'; +import { useAuth, useConfirmDialog } from '../hooks'; +import { tooltipStyle, DeleteIconWithTooltip, ConfirmDialog } from './index'; +import { PushPin, PushPinOutlined } from '@mui/icons-material'; +import { + Tooltip, + ListItemIcon, + Button, + ListItem, + ListItemButton, +} from '@mui/material'; +import { buttonStyle } from '../views'; import './SingleList.css'; const deletionResponse = { @@ -15,12 +20,6 @@ const deletionResponse = { soft: `List removed from user view.`, }; -export const buttonStyle = { - color: 'white', - width: '15em', - fontSize: '1.5rem', -}; - export function SingleList({ item, setListPath, @@ -69,7 +68,7 @@ export function SingleList({ handleDelete, title: `Are you sure you want to delete ${name}?`, setOpen: isOpen, - open: open, + open, }; const importantStatusLabel = isImportant ? 'Unpin list' : 'Pin list'; @@ -77,38 +76,41 @@ export function SingleList({ return ( <> {open && } -
  • setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + secondaryAction={ + + } > - {importantStatusLabel}

    } - placement="left" - arrow - > - + {importantStatusLabel}

    } + placement="left" + arrow > - {isImportant ? ( - - ) : ( - - )} -
    -
    - - - - -
  • + ); } diff --git a/src/components/TextInputElement.jsx b/src/components/TextInputElement.jsx index f76414c..28d2c29 100644 --- a/src/components/TextInputElement.jsx +++ b/src/components/TextInputElement.jsx @@ -1,3 +1,30 @@ +import { TextField, Typography } from '@mui/material'; +import { typographyStyle } from '../components/index'; + +const textFieldStyle = { + '& .MuiInputBase-input': { + fontSize: '1.7rem', + color: 'white', + borderColor: 'white', + }, + '& .MuiInputBase-input::placeholder': { + color: 'white', + fontSize: '1.5rem', + letterSpacing: '0.1rem', + }, + '& .MuiOutlinedInput-root': { + '& fieldset': { + borderColor: 'primary.main', // Set the border color to primary.main + }, + '&:hover fieldset': { + borderColor: 'primary.main', // Set border color on hover + }, + '&.Mui-focused fieldset': { + borderColor: 'primary.main', // Set border color when focused + }, + }, +}; + export function TextInputElement({ label, type, @@ -8,14 +35,26 @@ export function TextInputElement({ }) { return ( <> - + + {label} +
    -
    diff --git a/src/components/index.js b/src/components/index.js index b1b83c6..11e0676 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -1,7 +1,9 @@ export * from './ListItem'; export * from './SingleList'; export * from './AddItems'; +export * from './ShareList'; export * from './TextInputElement'; export * from './RadioInputElement'; export * from './ConfirmDialog'; export * from './DeleteIconWithTooltip'; +export * from './MUIStyles'; diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx index d8d7b51..1282ff2 100644 --- a/src/hooks/useAuth.jsx +++ b/src/hooks/useAuth.jsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'; import { auth } from '../api/config.js'; import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth'; import { addUserToDatabase } from '../api/firebase.js'; +import { Button } from '@mui/material'; +import { buttonStyle } from '../views/Home.jsx'; /** * A button that signs the user in using Google OAuth. When clicked, @@ -9,21 +11,28 @@ import { addUserToDatabase } from '../api/firebase.js'; * After the user signs in through the popup, it closes and the user becomes signed in. */ export const SignInButton = () => ( - + ); /** * A button that signs the user out of the app using Firebase Auth. */ export const SignOutButton = () => ( - + ); /** diff --git a/src/hooks/useUrgency.js b/src/hooks/useUrgency.js index aedfaea..7fa1942 100644 --- a/src/hooks/useUrgency.js +++ b/src/hooks/useUrgency.js @@ -5,8 +5,8 @@ export function useUrgency(items) { const [urgencyObject, setUrgencyObject] = useState({ overdue: new Set(), soon: new Set(), - kindOfSoon: new Set(), - notSoon: new Set(), + 'kind of soon': new Set(), + 'not soon': new Set(), inactive: new Set(), }); @@ -16,8 +16,8 @@ export function useUrgency(items) { let initialUrgencyState = { overdue: new Set(), soon: new Set(), - kindOfSoon: new Set(), - notSoon: new Set(), + 'kind of soon': new Set(), + 'not soon': new Set(), inactive: new Set(), }; diff --git a/src/utils/urgencyUtils.js b/src/utils/urgencyUtils.js index d1aede2..a198fb9 100644 --- a/src/utils/urgencyUtils.js +++ b/src/utils/urgencyUtils.js @@ -10,9 +10,9 @@ export const sortByUrgency = (item, daysUntilNextPurchase) => { } else if (daysUntilNextPurchase < 7) { return 'soon'; } else if (daysUntilNextPurchase >= 7 && daysUntilNextPurchase < 30) { - return 'kindOfSoon'; + return 'kind of soon'; } else if (daysUntilNextPurchase >= 30) { - return 'notSoon'; + return 'not soon'; } else { throw new Error(`Failed to place [${item.name}]`); } diff --git a/src/views/Home.jsx b/src/views/Home.jsx index dc3219b..064947a 100644 --- a/src/views/Home.jsx +++ b/src/views/Home.jsx @@ -1,14 +1,25 @@ +import { Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; import { createList } from '../api'; import { toast } from 'react-toastify'; import { useImportance } from '../hooks'; -import { ButtonGroup, Button } from '@mui/material'; -import { SingleList, TextInputElement } from '../components'; +import { + Paper, + Box, + Divider, + Button, + List as UnorderedList, +} from '@mui/material'; +import { + SingleList, + TextInputElement, + lightPaperStyle, + darkPaperStyle, +} from '../components'; import './Home.css'; -export const buttonStyle = { - color: 'white', - fontSize: '1.5rem', +const dividerStyle = { + borderColor: 'primary.main', }; export function Home({ data, setListPath, userId, userEmail }) { @@ -42,36 +53,44 @@ export function Home({ data, setListPath, userId, userEmail }) { }; return ( -
    -
    - - - + +
    + + + + + + -
      - + {sortedLists.map((item, index) => { return ( - + + + + ); })} - -
    -
    + +
    + ); } diff --git a/src/views/Layout.jsx b/src/views/Layout.jsx index ff58ad7..346b030 100644 --- a/src/views/Layout.jsx +++ b/src/views/Layout.jsx @@ -1,18 +1,9 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ import { Outlet, NavLink } from 'react-router-dom'; import { useAuth, SignInButton, SignOutButton } from '../hooks/useAuth'; -// import { Home, List, ManageList } from '../views'; import './Layout.css'; -/** - * TODO: The links defined in this file don't work! - * - * Instead of anchor element, they should use a component - * from `react-router-dom` to navigate to the routes - * defined in `App.jsx`. - */ - export function Layout() { const { user } = useAuth(); diff --git a/src/views/List.jsx b/src/views/List.jsx index 4f5727a..98d950b 100644 --- a/src/views/List.jsx +++ b/src/views/List.jsx @@ -1,8 +1,23 @@ import React, { useState } from 'react'; import { useEnsureListPath, useUrgency } from '../hooks'; import { getUrgency } from '../utils/urgencyUtils'; -import { List as UnorderedList, Box, Grid } from '@mui/material'; -import { ListItem, AddItems, TextInputElement } from '../components'; +import { + List as UnorderedList, + Box, + Grid, + Paper, + Typography, + Collapse, + Button, +} from '@mui/material'; +import { ArrowDropDown, ArrowDropUp } from '@mui/icons-material'; +import { + ListItem, + AddItems, + TextInputElement, + darkPaperStyle, + lightPaperStyle, +} from '../components'; // React.memo is needed to prevent unnecessary re-renders of the List component // when the props (data and listPath) haven't changed, @@ -11,6 +26,7 @@ import { ListItem, AddItems, TextInputElement } from '../components'; export const List = React.memo(function List({ data, listPath }) { const [searchItem, setSearchItem] = useState(''); + const [showAddItems, setShowAddItems] = useState(false); const { urgencyObject } = useUrgency(data); // Redirect to home if no list path is null @@ -28,43 +44,74 @@ export const List = React.memo(function List({ data, listPath }) { item?.name?.toLowerCase().includes(searchItem.toLowerCase()), ); + const handleAddItems = () => { + setShowAddItems((prev) => !prev); + }; + return ( - <> + {!data?.length ? ( <> -

    Welcome to {listName}!

    -

    Ready to add your first item? Start adding below!

    + + Welcome to {listName}! + + + Ready to add your first item? Start adding below! + ) : ( <> -

    {listName}

    - + - - + + {listName} - -
    event.preventDefault()}> - - + +
    + {showAddItems && ( + + + + + + + + )} + + + event.preventDefault()}> + + + + {filteredItems.map((item) => { const itemUrgencyStatus = getUrgency(item.name, urgencyObject); @@ -80,6 +127,6 @@ export const List = React.memo(function List({ data, listPath }) { )} - +
    ); }); diff --git a/src/views/ManageList.jsx b/src/views/ManageList.jsx index f9857ea..3461002 100644 --- a/src/views/ManageList.jsx +++ b/src/views/ManageList.jsx @@ -1,14 +1,25 @@ -import { AddItems } from '../components/AddItems'; -import { ShareList } from '../components/ShareList'; import { useEnsureListPath } from '../hooks/useEnsureListPath'; +import { Box, Paper } from '@mui/material'; +import { AddItems, ShareList } from '../components'; +import { darkPaperStyle, lightPaperStyle } from '../App'; export function ManageList({ items }) { // Redirect to home if no list path is null if (useEnsureListPath()) return <>; + return ( -
    - - -
    + + + + + + + + + + ); } diff --git a/tests/List.test.jsx b/tests/List.test.jsx index 0323fa5..c281b1b 100644 --- a/tests/List.test.jsx +++ b/tests/List.test.jsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { userEvent } from '@testing-library/user-event'; import { List } from '../src/views/List'; import { mockShoppingListData } from '../src/mocks/__fixtures__/shoppingListData'; import { useStateWithStorage, useEnsureListPath } from '../src/hooks'; @@ -21,8 +22,8 @@ vi.mock('../src/hooks', () => ({ urgencyObject: { overdue: [{ name: 'nutella', id: '0T1ByXr8YJSOzujOlLMI' }], soon: [{ name: 'Cheese', id: '1MFWOWMCzDtEHQboFZfR' }], - kindOfSoon: [], - notSoon: [{ name: 'Jam', id: 'MnUiYUmhg8iCzX1eMxW8' }], + 'kind of soon': [], + 'not soon': [{ name: 'Jam', id: 'MnUiYUmhg8iCzX1eMxW8' }], inactive: [], }, })), @@ -58,6 +59,23 @@ beforeEach(() => { }); describe('List Component', () => { + test('shows AddItems component with existing items', async () => { + render( + + + , + ); + + const dropDownIcon = await screen.findByTestId('new-item-button'); + await userEvent.click(dropDownIcon); + + expect(screen.getByLabelText('Add item:')).toBeInTheDocument(); + expect(screen.getByLabelText('Soon')).toBeInTheDocument(); + expect(screen.getByLabelText('Kind of soon')).toBeInTheDocument(); + expect(screen.getByLabelText('Not soon')).toBeInTheDocument(); + expect(screen.getByText('Submit')).toBeInTheDocument(); + }); + test('renders the shopping list name, search field, and all list items from the data prop', () => { render( @@ -66,7 +84,7 @@ describe('List Component', () => { ); expect(screen.getByText('groceries')).toBeInTheDocument(); - expect(screen.getByLabelText('Search Item:')).toBeInTheDocument(); + expect(screen.getByLabelText('Search for item:')).toBeInTheDocument(); mockShoppingListData.forEach((item) => { expect(screen.getByText(item.name)).toBeInTheDocument(); @@ -81,7 +99,7 @@ describe('List Component', () => { ); expect(screen.getByText('Welcome to groceries!')).toBeInTheDocument(); - expect(screen.getByLabelText('Item Name:')).toBeInTheDocument(); + expect(screen.getByLabelText('Add item:')).toBeInTheDocument(); expect(screen.getByLabelText('Soon')).toBeInTheDocument(); expect(screen.getByLabelText('Kind of soon')).toBeInTheDocument(); expect(screen.getByLabelText('Not soon')).toBeInTheDocument(); @@ -108,18 +126,4 @@ describe('List Component', () => { 'It seems like you landed here without first creating a list or selecting an existing one. Please select or create a new list first. Redirecting to Home.', ); }); - - test('shows AddItems component with existing items', () => { - render( - - - , - ); - - expect(screen.getByLabelText('Item Name:')).toBeInTheDocument(); - expect(screen.getByLabelText('Soon')).toBeInTheDocument(); - expect(screen.getByLabelText('Kind of soon')).toBeInTheDocument(); - expect(screen.getByLabelText('Not soon')).toBeInTheDocument(); - expect(screen.getByText('Submit')).toBeInTheDocument(); - }); }); diff --git a/tests/ManageList.test.jsx b/tests/ManageList.test.jsx index 9609a9d..fa14008 100644 --- a/tests/ManageList.test.jsx +++ b/tests/ManageList.test.jsx @@ -26,7 +26,7 @@ describe('ManageList Component', () => { , ); - expect(screen.getByLabelText('Item Name:')).toBeInTheDocument(); + expect(screen.getByLabelText('Add item:')).toBeInTheDocument(); expect(screen.getByLabelText('Soon')).toBeInTheDocument(); expect(screen.getByLabelText('Kind of soon')).toBeInTheDocument(); expect(screen.getByLabelText('Not soon')).toBeInTheDocument(); @@ -41,6 +41,6 @@ describe('ManageList Component', () => { ); expect(screen.getByPlaceholderText('Enter email')).toBeInTheDocument(); - expect(screen.getByText('Invite User')).toBeInTheDocument(); + expect(screen.getByText('Share')).toBeInTheDocument(); }); }); diff --git a/tests/SingleList.test.jsx b/tests/SingleList.test.jsx index 26df4fb..0560ead 100644 --- a/tests/SingleList.test.jsx +++ b/tests/SingleList.test.jsx @@ -18,6 +18,11 @@ vi.mock('../src/hooks', () => ({ useAuth: () => ({ user: { uid: mockUser.uid, email: mockUser.email }, }), + useConfirmDialog: () => ({ + open: true, + isOpen: vi.fn(), + toggleDialog: vi.fn(), + }), })); describe('SingleList component', () => { diff --git a/tests/sortByUrgency.test.js b/tests/sortByUrgency.test.js index ef18092..d064857 100644 --- a/tests/sortByUrgency.test.js +++ b/tests/sortByUrgency.test.js @@ -16,18 +16,18 @@ describe('sortByUrgency', () => { expect(result).toBe('soon'); }); - it('should return "kindOfSoon" if daysUntilNextPurchase is between 7 and 30', () => { + it('should return "kind of soon" if daysUntilNextPurchase is between 7 and 30', () => { const item = { name: 'Jam' }; const daysUntilNextPurchase = 15; const result = sortByUrgency(item, daysUntilNextPurchase); - expect(result).toBe('kindOfSoon'); + expect(result).toBe('kind of soon'); }); - it('should return "notSoon" if daysUntilNextPurchase is 30 or more', () => { + it('should return "not soon" if daysUntilNextPurchase is 30 or more', () => { const item = { name: 'Nutella' }; const daysUntilNextPurchase = 30; const result = sortByUrgency(item, daysUntilNextPurchase); - expect(result).toBe('notSoon'); + expect(result).toBe('not soon'); }); it('should throw an error if daysUntilNextPurchase cannot be classified', () => {