added ability to add budget

This commit is contained in:
florian.kaulfersch 2023-04-04 09:46:06 +02:00
parent 556a1567bd
commit 0eb368d2a0
15 changed files with 1170 additions and 51 deletions

946
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,12 +11,17 @@
"codegen": "rm -rf ./src/generated/openapi; openapi --input ./api.yaml --output ./src/generated/openapi"
},
"dependencies": {
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fortawesome/fontawesome-svg-core": "^6.3.0",
"@fortawesome/free-regular-svg-icons": "^6.3.0",
"@fortawesome/free-solid-svg-icons": "^6.3.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@mui/material": "^5.11.15",
"@mui/x-date-pickers": "^6.0.4",
"@sindresorhus/is": "^5.3.0",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-query-devtools": "^4.28.0",
"@types/node": "18.15.5",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",

View file

@ -1,33 +1,71 @@
import { Button, Grid, TextField } from '@mui/material';
import { SubmitHandler, useForm } from 'react-hook-form';
import { resolver } from './resolver';
import { useAddBudget } from '../../hooks/useAddBudget';
import { NewBudgetModel } from '../../models/budget';
import { Card } from '../_design/Card/Card';
import { DatePicker } from '../DatePicker/DatePicker';
// TODO check if necessary for type safety
// const resolver: Resolver<NewBudgetModel> = async (values: unknown) => ({
// values: newBudgetModelSchema.parse(values),
// errors: {},
// });
export const AddBudgetForm = () => {
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<NewBudgetModel>();
const { mutate: addBudget } = useAddBudget();
const onSubmit: SubmitHandler<NewBudgetModel> = (budgetItem: NewBudgetModel) => {
console.log(budgetItem);
addBudget(budgetItem);
} = useForm<NewBudgetModel>({ resolver });
const { mutate: addBudget, isLoading } = useAddBudget();
const onSubmit: SubmitHandler<NewBudgetModel> = async (budgetItem) => {
await addBudget(budgetItem);
reset();
};
const { ref, ...nameStuff } = register('name', { required: true });
return (
<Card>
<form onSubmit={handleSubmit(onSubmit)}>
<input type="text" placeholder="Budget name" {...register('name', { required: true })} />
{errors.name && <span>This field is required</span>}
<input type="number" defaultValue={0} {...register('limit', { min: 0, required: true })} />
{errors.limit && <span>This field is required</span>}
<input type="submit" />
<Grid container alignItems="flex-end" rowGap={4} columnGap={2}>
<Grid item xs={12} sm={6} lg={4}>
<TextField
fullWidth
variant="standard"
type="text"
label="Budget name"
inputRef={ref}
{...nameStuff}
error={!!errors.name}
/>
{errors.name && <>ERROR!</>}
</Grid>
<Grid item lg={4} xs={12} sm={5.5}>
<TextField
fullWidth
variant="standard"
type="number"
label="Limit"
{...register('limit', { min: 0, required: true })}
error={!!errors.limit}
/>
</Grid>
<Grid item lg={4} sm={5} md={4} xs={12}>
<DatePicker control={control} name="startDate" label="Start" />
</Grid>
<Grid item lg={4} sm={5} md={4} xs={12}>
<DatePicker control={control} name="endDate" label="End" />
</Grid>
<Grid item lg sm md justifyContent={'center'}>
<Button
type="submit"
size="large"
variant="contained"
disabled={isLoading || Object.keys(errors).length > 0}
>
Create
</Button>
</Grid>
</Grid>
</form>
</Card>
);

View file

@ -0,0 +1,44 @@
import { isBefore } from 'date-fns';
import { Resolver } from 'react-hook-form';
import { z } from 'zod';
import { NewBudgetModel } from '../../models/budget';
const budgetFormSchema = z
.object({
name: z.string(),
limit: z.string(),
startDate: z.date().optional(),
endDate: z.date().optional(),
})
.transform<NewBudgetModel>((formValues) => ({
...formValues,
startDate: formValues.startDate,
endDate: formValues.endDate,
limit: parseInt(formValues.limit),
}));
export const resolver: Resolver<NewBudgetModel> = async (values: unknown) => {
const parseResult = budgetFormSchema.safeParse(values);
if (!parseResult.success) {
return {
values: parseResult,
errors: { root: { message: parseResult.error.message } },
};
} else {
// check whether dates are set up correctly
const { startDate, endDate } = parseResult.data;
if (startDate && endDate && isBefore(endDate, startDate)) {
return {
values: parseResult.data,
errors: {
startDate: { type: 'value', message: 'Start date has to be before end date' },
},
};
}
return {
values: parseResult.data,
errors: {},
};
}
};

View file

@ -17,7 +17,7 @@ type BudgetItemProps = { budget: BudgetModel };
export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
const { id, spent, name, limit, startDate, endDate } = budget;
const spentPercentage = Math.trunc((100 * spent) / limit);
const remainingBudget = 100 - Math.trunc((100 * spent) / limit);
const isValidDateRange = is.date(startDate) && is.date(endDate) && endDate > startDate;
@ -29,7 +29,7 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
</h3>
<DetailWithTitle title={'Remaining Budget'}>
<ProgressBar percentage={spentPercentage} />
<ProgressBar percentage={remainingBudget} />
</DetailWithTitle>
{isValidDateRange && (

View file

@ -1,11 +1,12 @@
import { library } from '@fortawesome/fontawesome-svg-core';
import { faAngry } from '@fortawesome/free-regular-svg-icons';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import { faPlus, faMinus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { FC, useState } from 'react';
import { FC } from 'react';
import styles from './BudgetList.module.scss';
import { useBudgets } from '../../hooks/useBudgets';
import { useToggle } from '../../hooks/useToggle';
import { Button } from '../_design/Button/Button';
import { AddBudgetForm } from '../AddBudgetForm/AddBudgetForm';
import { BudgetItem } from '../BudgetItem/BudgetItem';
@ -14,17 +15,12 @@ library.add(faAngry);
export const BudgetList: FC = () => {
const { data: budgets = [] } = useBudgets();
const [showAddBudget, setShowAddBudget] = useState(false);
const handleAddBudgetClick = () => {
// TODO create logic for adding budget
setShowAddBudget(true);
};
const [showAddBudget, toggleShowAddBudget] = useToggle(false);
return (
<section className={styles.Container}>
<Button onClick={handleAddBudgetClick}>
Add a budget <FontAwesomeIcon icon={faPlus} />
<Button onClick={toggleShowAddBudget}>
Add a budget <FontAwesomeIcon icon={showAddBudget ? faMinus : faPlus} />
</Button>
{showAddBudget && <AddBudgetForm />}
<div className={styles.ListItems}>

View file

@ -0,0 +1,17 @@
.Wrapper {
display: block;
position: relative;
}
.Error {
position: absolute;
bottom: -16px;
left: 2px;
font-size: 12px;
color: red;
padding: 0 8px;
}
.DatePicker {
width: 100%
}

View file

@ -0,0 +1,34 @@
import { DatePicker as MUIDatePicker } from '@mui/x-date-pickers';
import { Control, Controller, FieldValues, Path } from 'react-hook-form';
import styles from './DatePicker.module.scss';
const DATE_FORMAT = 'dd.MM.yyyy';
export const DatePicker = <ControlType extends FieldValues>({
control,
name,
label,
}: {
control: Control<ControlType>;
name: Path<ControlType>;
label?: string;
}) => {
return (
<Controller
control={control}
render={({ field: { value, ...rest }, fieldState: { error } }) => (
<div className={styles.Wrapper}>
<MUIDatePicker
className={styles.DatePicker}
label={label && <span>{label}</span>}
format={DATE_FORMAT}
{...rest}
value={value ? new Date(value) : null}
/>
{error && <span className={styles.Error}>{error.message}</span>}
</div>
)}
name={name}
/>
);
};

View file

@ -4,11 +4,11 @@
import type { Budget } from '../models/Budget';
import type { BudgetId } from '../models/BudgetId';
import type { BudgetSummary } from '../models/BudgetSummary';
import type { NewBudget } from '../models/NewBudget';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
import {NewBudgetModel} from "../../../models/budget";
export class BudgetsService {
@ -19,8 +19,9 @@ export class BudgetsService {
* @throws ApiError
*/
public static createBudget(
requestBody: NewBudget,
requestBody: NewBudgetModel,
): CancelablePromise<Budget> {
console.log('requestBody: ', requestBody);
return __request(OpenAPI, {
method: 'POST',
url: '/budgets',
@ -85,5 +86,4 @@ export class BudgetsService {
},
});
}
}

View file

@ -1,14 +1,19 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { Budget, BudgetsService, NewBudget } from '../generated/openapi';
import { useInvalidateQuery } from './useBudgets';
import { Budget, BudgetsService } from '../generated/openapi';
import { NewBudgetModel } from '../models/budget';
export const useAddBudget = (): UseMutationResult<Budget, unknown, NewBudgetModel> =>
useMutation((budget: NewBudgetModel) => {
const mappedBudget: NewBudget = {
...budget,
startDate: budget.startDate?.toISOString(),
endDate: budget.endDate?.toISOString(),
};
return BudgetsService.createBudget(mappedBudget);
});
export const useAddBudget = (): UseMutationResult<Budget, unknown, NewBudgetModel> => {
const invalidateBudgetQuery = useInvalidateQuery();
return useMutation(
(budget: NewBudgetModel) => {
console.log('budget: ', budget);
return BudgetsService.createBudget(budget);
},
{
onSuccess: () => invalidateBudgetQuery(),
},
);
};

View file

@ -1,9 +1,9 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
import { Budget, BudgetsService } from '../generated/openapi';
import { BudgetModel, mapToBudgetModel } from '../models/budget';
const budgetSymbol = Symbol('getBudgets');
const budgetsQueryKey = ['/budgets'];
const selectValidBudgetModelsFromBudgetDtos = (budgetDtos: Budget[]) =>
budgetDtos.reduce<BudgetModel[]>((budgetModels, budgetDto) => {
@ -12,9 +12,14 @@ const selectValidBudgetModelsFromBudgetDtos = (budgetDtos: Budget[]) =>
return mappedBudget ? [...budgetModels, mappedBudget] : budgetModels;
}, []);
export const useInvalidateQuery = () => {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries(budgetsQueryKey);
};
export const useBudgets = (): UseQueryResult<BudgetModel[]> =>
useQuery({
queryKey: [budgetSymbol],
queryKey: budgetsQueryKey,
queryFn: () => BudgetsService.getBudgets(),
select: selectValidBudgetModelsFromBudgetDtos,
});

14
src/hooks/useToggle.ts Normal file
View file

@ -0,0 +1,14 @@
import { useCallback, useState } from 'react';
/**
* Custom hook to toggle a boolean value.
* @param initialValue
*/
export const useToggle = (initialValue: boolean) => {
const [state, setState] = useState(initialValue);
const toggleState = useCallback(() => {
setState((oldValue) => !oldValue);
}, []);
return [state, toggleState] as const;
};

View file

@ -11,7 +11,7 @@ export const newBudgetModelSchema = z.object({
export const budgetModelSchema = newBudgetModelSchema.extend({
id: z.string(),
spent: z.number().positive(),
spent: z.number().nonnegative(),
});
export type BudgetModel = z.infer<typeof budgetModelSchema>;

View file

@ -1,6 +1,9 @@
import { config } from '@fortawesome/fontawesome-svg-core';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { QueryClient } from '@tanstack/query-core';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Inter } from 'next/font/google';
import React from 'react';
@ -20,12 +23,15 @@ const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWind
export default function App({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<main className={inter.className}>
<Navigation />
<section className={appStyles.ContentWrapper}>
<Component {...pageProps} />
</section>
</main>
<ReactQueryDevtools />
</LocalizationProvider>
</QueryClientProvider>
);
}

View file

@ -8,5 +8,14 @@ export const budgetList = budgetFactory.buildList(8);
budgetFactory.rewindSequence();
export default function handler(_req: NextApiRequest, res: NextApiResponse<Budget[]>) {
res.status(200).json(budgetList);
switch (_req.method) {
case 'POST':
const newBudget: Budget = { ..._req.body, id: _req.body.name, spent: 0 };
budgetList.push(newBudget);
return res.status(200).json(budgetList);
case 'GET':
default:
console.log('budgetList: ', budgetList);
return res.status(200).json(budgetList);
}
}