diff --git a/package-lock.json b/package-lock.json index 240aecf..94349f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6611,6 +6611,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-3.0.1.tgz", "integrity": "sha512-sxpmMKxqLvcscu6mFn9ITHeZNkGzIvD0BSNFE/LJESPbCA8s1jM6bCDPjWbV31xHq7JXaxgpHxLB54RCbBZSlg==", + "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.0.0", "prop-types": "^15.6.0" diff --git a/src/api/lib.test.ts b/src/api/lib.test.ts new file mode 100644 index 0000000..4e8a0ed --- /dev/null +++ b/src/api/lib.test.ts @@ -0,0 +1,63 @@ +import axios from 'axios' +import { user } from '../data/user' +import { API_ROOT, getGoals } from './lib' +import { Goal } from './types' + +jest.mock('axios') + +const mockedAxios = axios as jest.Mocked + +describe('getGoals', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('calls GetGoalsForUser route and returns goals for the current user', async () => { + const goals: Goal[] = [ + { + id: 'goal-1', + name: 'Holiday', + targetAmount: 2500, + balance: 100, + targetDate: new Date('2027-01-01'), + created: new Date('2026-01-01'), + accountId: 'account-1', + transactionIds: [], + tagIds: [], + icon: '🚀', + }, + { + id: 'goal-2', + name: 'Emergency', + targetAmount: 5000, + balance: 800, + targetDate: new Date('2027-06-01'), + created: new Date('2026-02-01'), + accountId: 'account-2', + transactionIds: [], + tagIds: [], + icon: '🎯', + }, + ] + + mockedAxios.get.mockResolvedValueOnce({ data: goals }) + + const result = await getGoals() + + expect(mockedAxios.get).toHaveBeenCalledWith(`${API_ROOT}/api/Goal/User/${user.id}`) + expect(result).not.toBeNull() + + for (const goal of result ?? []) { + expect(goal).toEqual(expect.any(Object)) + expect(goal).toEqual(expect.objectContaining({ id: expect.any(String) })) + } + }) + + it('returns null when request fails', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('request failed')) + + const result = await getGoals() + + expect(result).toBeNull() + }) +}) diff --git a/src/api/types.ts b/src/api/types.ts index f75edad..6d2d5db 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -27,6 +27,7 @@ export interface Goal { accountId: string transactionIds: string[] tagIds: string[] + icon: string | null } export interface Tag { diff --git a/src/ui/components/EmojiPicker.tsx b/src/ui/components/EmojiPicker.tsx index 00bb54d..e4af04c 100644 --- a/src/ui/components/EmojiPicker.tsx +++ b/src/ui/components/EmojiPicker.tsx @@ -1,9 +1,9 @@ -import { BaseEmoji, Picker } from 'emoji-mart' +import { EmojiData, Picker } from 'emoji-mart' import 'emoji-mart/css/emoji-mart.css' import { useAppSelector } from '../../store/hooks' import { selectMode } from '../../store/themeSlice' -type Props = { onClick: (emoji: BaseEmoji, event: React.MouseEvent) => void } +type Props = { onClick: (emoji: EmojiData) => void } export default function EmojiPicker(props: Props) { const theme = useAppSelector(selectMode) @@ -13,7 +13,7 @@ export default function EmojiPicker(props: Props) { theme={theme} showPreview={false} showSkinTones={false} - onClick={props.onClick} + onClick={(emoji) => props.onClick(emoji)} color="primary" /> ) diff --git a/src/ui/features/goalmanager/GoalManager.tsx b/src/ui/features/goalmanager/GoalManager.tsx index 0779dda..815f75b 100644 --- a/src/ui/features/goalmanager/GoalManager.tsx +++ b/src/ui/features/goalmanager/GoalManager.tsx @@ -2,6 +2,7 @@ import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons' import { faDollarSign, IconDefinition } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { MaterialUiPickersDate } from '@material-ui/pickers/typings/date' +import { EmojiData } from 'emoji-mart' import 'date-fns' import React, { useEffect, useState } from 'react' import styled from 'styled-components' @@ -10,7 +11,10 @@ import { Goal } from '../../../api/types' import { selectGoalsMap, updateGoal as updateGoalRedux } from '../../../store/goalsSlice' import { useAppDispatch, useAppSelector } from '../../../store/hooks' import DatePicker from '../../components/DatePicker' +import EmojiPicker from '../../components/EmojiPicker' import { Theme } from '../../components/Theme' +import AddIconButton from './AddIconButton' +import GoalIcon from './GoalIcon' type Props = { goal: Goal } export function GoalManager(props: Props) { @@ -21,64 +25,81 @@ export function GoalManager(props: Props) { const [name, setName] = useState(null) const [targetDate, setTargetDate] = useState(null) const [targetAmount, setTargetAmount] = useState(null) + const [icon, setIcon] = useState(null) + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false) useEffect(() => { setName(props.goal.name) setTargetDate(props.goal.targetDate) setTargetAmount(props.goal.targetAmount) + setIcon(props.goal.icon) }, [ props.goal.id, props.goal.name, props.goal.targetDate, props.goal.targetAmount, + props.goal.icon, ]) useEffect(() => { setName(goal.name) - }, [goal.name]) + setTargetDate(goal.targetDate) + setTargetAmount(goal.targetAmount) + setIcon(goal.icon) + }, [goal.name, goal.targetDate, goal.targetAmount, goal.icon]) - const updateNameOnChange = (event: React.ChangeEvent) => { - const nextName = event.target.value - setName(nextName) + const persistGoal = (updates: Partial) => { const updatedGoal: Goal = { - ...props.goal, - name: nextName, + ...goal, + ...updates, } + dispatch(updateGoalRedux(updatedGoal)) - updateGoalApi(props.goal.id, updatedGoal) + updateGoalApi(updatedGoal.id, updatedGoal) + } + + const updateNameOnChange = (event: React.ChangeEvent) => { + const nextName = event.target.value + setName(nextName) + persistGoal({ name: nextName }) } const updateTargetAmountOnChange = (event: React.ChangeEvent) => { const nextTargetAmount = parseFloat(event.target.value) setTargetAmount(nextTargetAmount) - const updatedGoal: Goal = { - ...props.goal, - name: name ?? props.goal.name, - targetDate: targetDate ?? props.goal.targetDate, - targetAmount: nextTargetAmount, - } - dispatch(updateGoalRedux(updatedGoal)) - updateGoalApi(props.goal.id, updatedGoal) + persistGoal({ targetAmount: nextTargetAmount }) } const pickDateOnChange = (date: MaterialUiPickersDate) => { if (date != null) { setTargetDate(date) - const updatedGoal: Goal = { - ...props.goal, - name: name ?? props.goal.name, - targetDate: date ?? props.goal.targetDate, - targetAmount: targetAmount ?? props.goal.targetAmount, - } - dispatch(updateGoalRedux(updatedGoal)) - updateGoalApi(props.goal.id, updatedGoal) + persistGoal({ targetDate: date }) } } + const toggleEmojiPicker = () => setIsEmojiPickerOpen((current) => !current) + + const pickEmojiOnClick = (emoji: EmojiData) => { + if (!("native" in emoji)) return + + setIsEmojiPickerOpen(false) + setIcon(emoji.native) + persistGoal({ icon: emoji.native }) + } + return ( + + + {icon != null && } + + + + + + @@ -111,9 +132,7 @@ export function GoalManager(props: Props) { } type FieldProps = { name: string; icon: IconDefinition } -type AddIconButtonContainerProps = { shouldShow: boolean } -type GoalIconContainerProps = { shouldShow: boolean } -type EmojiPickerContainerProps = { isOpen: boolean; hasIcon: boolean } +type EmojiPickerContainerProps = { isOpen: boolean } const Field = (props: FieldProps) => ( @@ -149,6 +168,17 @@ const NameInput = styled.input` color: ${({ theme }: { theme: Theme }) => theme.text}; ` +const IconsContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; +` + +const EmojiPickerContainer = styled.div` + display: ${({ isOpen }: EmojiPickerContainerProps) => (isOpen ? 'block' : 'none')}; + margin-top: 1.5rem; +` + const FieldName = styled.h1` font-size: 1.8rem; margin-left: 1rem; diff --git a/src/ui/pages/Main/goals/GoalCard.tsx b/src/ui/pages/Main/goals/GoalCard.tsx index e8f6d0a..b6ecb4b 100644 --- a/src/ui/pages/Main/goals/GoalCard.tsx +++ b/src/ui/pages/Main/goals/GoalCard.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector } from '../../../../store/hooks' import { setContent as setContentRedux, setIsOpen as setIsOpenRedux, - setType as setTypeRedux + setType as setTypeRedux, } from '../../../../store/modalSlice' import { Card } from '../../../components/Card' @@ -29,6 +29,7 @@ export default function GoalCard(props: Props) { ${goal.targetAmount} {asLocaleDateString(goal.targetDate)} + {goal.icon != null && {goal.icon}} ) } @@ -43,9 +44,9 @@ const Container = styled(Card)` margin-left: 2rem; margin-right: 2rem; border-radius: 2rem; - align-items: center; ` + const TargetAmount = styled.h2` font-size: 2rem; ` @@ -54,3 +55,7 @@ const TargetDate = styled.h4` color: rgba(174, 174, 174, 1); font-size: 1rem; ` + +const Icon = styled.h1` + font-size: 5.5rem; +`