Adding schema validation, detail page init setup.

This commit is contained in:
Simon Wanner 2023-03-24 09:08:21 +01:00
parent 3b5e82d31c
commit 58f8194e5f
24 changed files with 295 additions and 64 deletions

11
package-lock.json generated
View file

@ -26,7 +26,8 @@
"react-dom": "18.2.0",
"react-sweet-progress": "^1.1.2",
"sass": "^1.59.3",
"typescript": "5.0.2"
"typescript": "5.0.2",
"zod": "^3.21.4"
},
"devDependencies": {
"eslint-config-prettier": "^8.8.0",
@ -3856,6 +3857,14 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.21.4.tgz",
"integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View file

@ -29,7 +29,8 @@
"react-dom": "18.2.0",
"react-sweet-progress": "^1.1.2",
"sass": "^1.59.3",
"typescript": "5.0.2"
"typescript": "5.0.2",
"zod": "^3.21.4"
},
"devDependencies": {
"eslint-config-prettier": "^8.8.0",

View file

@ -1,3 +1,43 @@
.Container {
.Headline1 {
position: relative;
text-align: center;
margin-bottom: 32px;
}
.ArrowBack {
font-size: 1.5rem;
position: absolute;
left: 0px;
top: 50%;
transform: translateY(-50%);
}
.Details {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.DetailsContainer {
display: flex;
gap: var(--grid-gap);
flex-wrap: wrap;
}
.DetailsContainerLeft {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--grid-gap);
flex-basis: 300px;
}
.DetailsContainerRight {
flex: 0;
flex-basis: 300px;
align-self: stretch;
& > *:first-child:last-child {
height: 100%;
}
}

View file

@ -1,9 +1,15 @@
import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import is from '@sindresorhus/is';
import Link from 'next/link';
import { FunctionComponent } from 'react';
import styles from './BudgetDetail.module.scss';
import { datesToDayRange, dateToFormattedDay } from '../../helpers/date';
import { useBudgetSummaryQuery } from '../../hooks/useBudgetSummaryQuery';
import { Button } from '../_design/Button/Button';
import { Card } from '../_design/Card/Card';
import { DetailWithTitle } from '../_design/DetailWithTitle/DetailWithTitle';
type BudgetDetailProps = {
id: string;
@ -13,19 +19,48 @@ export const BudgetDetail: FunctionComponent<BudgetDetailProps> = ({ id }) => {
const budgetQuery = useBudgetSummaryQuery(id);
const budget = budgetQuery.data;
if (is.undefined(budget) || budgetQuery.isFetching) {
if (is.nullOrUndefined(budget) || budgetQuery.isFetching) {
return null;
}
const { startDate, endDate, name, limit, transactions } = budget;
const isValidDateRange = is.date(startDate) && is.date(endDate) && startDate < endDate;
return (
<section className={styles.DetailsContainer}>
<div className={styles.DetailsContainerLeft}>
<Card>
<h1>{budget.name} </h1>
<div className={styles.ProgressBar}>
<div className={styles.ProgressPercentage}></div>
<h1 className={styles.Headline1}>
<Link href={'/'}>
<FontAwesomeIcon icon={faArrowLeft} className={styles.ArrowBack} />
</Link>
{name}
</h1>
<div className={styles.Details}>
<DetailWithTitle title={'Limit'}>{limit}</DetailWithTitle>
{isValidDateRange && (
<DetailWithTitle title={'Period'}>{datesToDayRange(startDate, endDate)}</DetailWithTitle>
)}
</div>
<span>Amount</span>
<span>Date- start -end</span>
<button onClick={() => null}>View transactions</button>
</Card>
<Card>
<h3>Transactions</h3>
<ul>
{transactions.map((transaction) => (
<li key={transaction.id}>
{dateToFormattedDay(transaction.date)} - {transaction.description}
</li>
))}
</ul>
</Card>
</div>
<div className={styles.DetailsContainerRight}>
<Card>
<h3>New Transaction</h3>
<Button onClick={() => {}}>Add Transaction</Button>
</Card>
</div>
</section>
);
};

View file

@ -1,25 +1,25 @@
import { faCoins } from '@fortawesome/free-solid-svg-icons/faCoins';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import format from 'date-fns/format';
import is from '@sindresorhus/is';
import Link from 'next/link';
import { FC } from 'react';
import { Progress } from 'react-sweet-progress';
import 'react-sweet-progress/lib/style.css';
import styles from './BudgetItem.module.scss';
import { Budget } from '../../generated/openapi';
import { datesToDayRange } from '../../helpers/date';
import { BudgetModel } from '../../models/budget';
import { Card } from '../_design/Card/Card';
import { DetailWithTitle } from '../_design/DetailWithTitle/DetailWithTitle';
import { ProgressBar } from '../_design/ProgressBar/ProgressBar';
type BudgetItemProps = { budget: Budget };
const DATE_FORMAT = 'dd.MM.yy';
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 startDateFormatted = format(new Date(startDate ?? ''), DATE_FORMAT);
const endDateFormatted = format(new Date(endDate ?? ''), DATE_FORMAT);
const isValidDateRange = is.date(startDate) && is.date(endDate) && endDate > startDate;
return (
<Card>
@ -27,17 +27,16 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
<h3 className={styles.Title}>
{name} <FontAwesomeIcon icon={faCoins} />
</h3>
<div>
<small>Remaining Budget</small>
<Progress percent={spentPercentage} theme={{ active: { color: '#84844c' } }} />
</div>
<div>
<small>Period</small>
<p>
{startDateFormatted} - {endDateFormatted}
</p>
</div>
<DetailWithTitle title={'Remaining Budget'}>
<ProgressBar percentage={spentPercentage} />
</DetailWithTitle>
{isValidDateRange && (
<DetailWithTitle title={'Period'}>
<p>{datesToDayRange(startDate, endDate)}</p>
</DetailWithTitle>
)}
<Link className={styles.Details} href={`/budget/${id}`}>
Show Details

View file

@ -6,6 +6,6 @@
.ListItems {
display: grid;
gap: 16px;
gap: var(--grid-gap);
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
}

View file

@ -0,0 +1,6 @@
.Detail {
display: flex;
align-items: stretch;
flex-direction: column;
gap: 4px;
}

View file

@ -0,0 +1,14 @@
import { FunctionComponent, PropsWithChildren } from 'react';
import styles from './DetailWithTitle.module.scss';
export interface DetailWithTitleProps extends PropsWithChildren {
readonly title: string;
}
export const DetailWithTitle: FunctionComponent<DetailWithTitleProps> = ({ children, title }) => (
<div className={styles.Detail}>
<small>{title}</small>
<div>{children}</div>
</div>
);

View file

@ -0,0 +1,10 @@
import { FunctionComponent } from 'react';
import { Progress } from 'react-sweet-progress';
export interface ProgressBarProps {
readonly percentage: number;
}
export const ProgressBar: FunctionComponent<ProgressBarProps> = ({ percentage }) => (
<Progress percent={percentage} theme={{ active: { color: '#84844c' } }} />
);

7
src/helpers/date.ts Normal file
View file

@ -0,0 +1,7 @@
import format from 'date-fns/format';
const DAY_FORMAT = 'dd.MM.yy';
export const dateToFormattedDay = (date: Date): string => format(date, DAY_FORMAT);
export const datesToDayRange = (start: Date, end: Date) => dateToFormattedDay(start) + ' ' + dateToFormattedDay(end);

View file

@ -1,13 +1,14 @@
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { BudgetsService, BudgetSummary } from '../generated/openapi';
import { BudgetsService } from '../generated/openapi';
import { BudgetSummaryModel, mapToBudgetSummaryModel } from '../models/budget-summary';
const queryKey = Symbol('getBudgetSummary');
export const useBudgetSummaryQuery = (id: string): UseQueryResult<BudgetSummary> => {
return useQuery({
export const useBudgetSummaryQuery = (id: string): UseQueryResult<BudgetSummaryModel | null> =>
useQuery({
queryKey: [queryKey, id],
queryFn: () => BudgetsService.getBudgetSummary(id),
keepPreviousData: true,
select: mapToBudgetSummaryModel,
});
};

View file

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

View file

@ -0,0 +1,8 @@
import is from '@sindresorhus/is';
import { z } from 'zod';
export const optionalDateSchema = z
.string()
.datetime()
.optional()
.transform((dataString) => (!is.undefined(dataString) ? new Date(dataString) : dataString));

View file

@ -0,0 +1,24 @@
import { z } from 'zod';
import { optionalDateSchema } from './_generic-schemas';
import { transactionSchema } from './transaction';
export const budgetSummaryModelSchema = z.object({
id: z.string(),
name: z.string(),
limit: z.number().positive(),
transactions: z.array(transactionSchema),
startDate: optionalDateSchema,
endDate: optionalDateSchema,
});
export type BudgetSummaryModel = z.infer<typeof budgetSummaryModelSchema>;
export const mapToBudgetSummaryModel = (data: unknown): BudgetSummaryModel | null => {
try {
return budgetSummaryModelSchema.parse(data);
} catch (e) {
console.error(e);
return null;
}
};

23
src/models/budget.ts Normal file
View file

@ -0,0 +1,23 @@
import { z } from 'zod';
import { optionalDateSchema } from './_generic-schemas';
export const budgetModelSchema = z.object({
id: z.string(),
name: z.string(),
limit: z.number().positive(),
spent: z.number().positive(),
startDate: optionalDateSchema,
endDate: optionalDateSchema,
});
export type BudgetModel = z.infer<typeof budgetModelSchema>;
export const mapToBudgetModel = (data: unknown): BudgetModel | null => {
try {
return budgetModelSchema.parse(data);
} catch (e) {
console.error(e);
return null;
}
};

13
src/models/transaction.ts Normal file
View file

@ -0,0 +1,13 @@
import { z } from 'zod';
export const transactionSchema = z.object({
id: z.string(),
description: z.string(),
amount: z.number().positive(),
date: z
.string()
.datetime()
.transform((dateTime) => new Date(dateTime)),
});
export type TransactionModel = z.infer<typeof transactionSchema>;

View file

@ -15,7 +15,7 @@ import type { AppProps } from 'next/app';
config.autoAddCss = false;
const inter = Inter({ subsets: ['latin'] });
const queryClient = new QueryClient();
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } });
export default function App({ Component, pageProps }: AppProps) {
return (

View file

@ -1,19 +1,19 @@
import is from '@sindresorhus/is';
import { Budget } from '../../../../generated/openapi';
import { budgetList } from '../index';
import { BudgetSummary } from '../../../../generated/openapi';
import { budgetSummaryFactory } from '../../factories/budget.factories';
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse<Budget>) {
const budgetSummaryMock = budgetSummaryFactory.buildList(8);
export default function handler(req: NextApiRequest, res: NextApiResponse<BudgetSummary>) {
const { query, method } = req;
const id = query.id as string;
switch (method) {
case 'GET':
const targetBudget = budgetList.find(({ id: budgetId }) => budgetId === id);
console.log(budgetList.map(({ id }) => id));
const targetBudget = budgetSummaryMock.find(({ id: budgetId }) => budgetId === id);
if (is.undefined(targetBudget)) {
res.status(404).end();

View file

@ -4,7 +4,6 @@ import { budgetFactory } from '../factories/budget.factories';
import type { NextApiRequest, NextApiResponse } from 'next';
// TODO fix this setup as on HMR this is created as well again which results in invalid data on detail pages (404)
export const budgetList = budgetFactory.buildList(8);
budgetFactory.rewindSequence();

View file

@ -1,14 +1,13 @@
import { randomUUID } from 'crypto';
import { Factory } from 'fishery';
import { Budget } from '../../../generated/openapi';
import { transactionFactory } from './transaction.factories';
import { Budget, BudgetSummary } from '../../../generated/openapi';
export const budgetFactory = Factory.define<Budget>(({ sequence }) => {
const limit = 200 + (sequence - 1) * 50;
return {
id: randomUUID(),
id: `budget-mock-id-${sequence}`,
startDate: '2023-02-21T18:49:13.620Z',
endDate: '2023-03-21T18:49:13.620Z',
name: 'Budget-' + sequence,
@ -16,3 +15,19 @@ export const budgetFactory = Factory.define<Budget>(({ sequence }) => {
spent: limit - Math.min(limit, sequence * 50),
};
});
export const budgetSummaryFactory = Factory.define<BudgetSummary>(({ sequence }) => {
const limit = 200 + (sequence - 1) * 50;
const transactions = transactionFactory.buildList(5);
transactionFactory.rewindSequence();
return {
id: `budget-mock-id-${sequence}`,
startDate: '2023-02-21T18:49:13.620Z',
endDate: '2023-03-21T18:49:13.620Z',
name: 'Budget-' + sequence,
limit,
transactions: transactions,
};
});

View file

@ -0,0 +1,10 @@
import { Factory } from 'fishery';
import { Transaction } from '../../../generated/openapi';
export const transactionFactory = Factory.define<Transaction>(({ sequence }) => ({
id: `transaction-id-${sequence}`,
amount: 5 * sequence,
date: '2023-03-21T18:49:13.620Z',
description: `Transaction description for transaction ${sequence}`,
}));

View file

@ -5,16 +5,7 @@ import { BudgetDetail } from '../../components/BudgetDetail/BudgetDetail';
export default function BudgetPage() {
const router = useRouter();
const { id } = router.query;
if (is.nonEmptyString(id)) {
return (
<>
<BudgetDetail id={id} />
</>
);
}
return null;
return !is.string(id) ? null : <BudgetDetail id={id} />;
}

View file

@ -4,7 +4,7 @@
--padding-inline: var(--padding-container);
@media (min-width: 980px) {
--content-width: clamp(980px, 60vw, var(--max-width));
--content-width: clamp(980px, 70vw, var(--max-width));
--padding-inline: 0px;
}

View file

@ -1,5 +1,5 @@
:root {
--max-width: 1100px;
--max-width: 1080px;
--border-radius: 4px;
--font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono',
'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro',
@ -49,6 +49,7 @@
/* Custom Dimensions */
--container-padding: 32px;
--grid-gap: 16px;
}
@media (prefers-color-scheme: dark) {
@ -107,6 +108,22 @@ body {
min-height: 100vh;
}
h1, h2, h3, h4, h4 {
font-weight: 500;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.6rem;
}
h3 {
font-size: 1.3rem;
}
a {
color: var(--primary-color);
text-decoration: none;