Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 6 - manual rate adding/editing #7

Merged
merged 2 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions src/components/CreateExchangeRateDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import LoadingButton from '@mui/lab/LoadingButton';
import { useTheme } from '@mui/material/styles';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField';
import useMediaQuery from '@mui/material/useMediaQuery';
import { type SubmitHandler, useForm, Controller } from 'react-hook-form';
import Stack from '@mui/material/Stack';
import { useCallback } from 'react';
import { enqueueSnackbar } from 'notistack';
import DialogContentText from '@mui/material/DialogContentText';
import { currencyOptionsById } from '@lib/autoCompleteOptions';
import { formatNumber } from '@lib/format';
import client from '@lib/client';
import CurrencyAutocomplete from './CurrencyAutocomplete';

type Props = {
open: boolean;
onClose: () => void;
};

const id = 'create-exchangerate-dialog';

type Option = { label: string; id: string };
type ExchgangeRateFormValues = {
currency: Option;
close: string;
};

export default function CreateExchangeRateDialog({ open, onClose }: Props) {
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down('md'));
const {
control,
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<ExchgangeRateFormValues>({
mode: 'onBlur',
defaultValues: {
currency: currencyOptionsById['USD'],
close: formatNumber(1.0),
},
});
const { mutate: createExchangeRate, isPending: isCreating } =
client.createExchangeRate.useMutation({
onSuccess: () => {
enqueueSnackbar({
message: 'Exchange rate created.',
variant: 'success',
});
onClose();
},
onError: (e) => {
enqueueSnackbar({
message: `Failed to create exchange rate. ${e.message}`,
variant: 'error',
});
},
});
const onSubmit: SubmitHandler<ExchgangeRateFormValues> = useCallback(
async (values) =>
createExchangeRate({
ticker: `EUR${values.currency.id}`,
close: parseFloat(values.close),
}),
[createExchangeRate],
);

return (
<Dialog
open={open}
onClose={onClose}
id={id}
aria-labelledby={`${id}-title`}
fullScreen={fullScreen}
keepMounted={false}
fullWidth
maxWidth="md"
>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogTitle id={`${id}-title`}>Edit exchange rate</DialogTitle>
<DialogContent>
<Stack gap={4}>
<DialogContentText fontStyle="italic" fontSize="sm">
🇪🇺 EUR is always the base currecy
</DialogContentText>
<Stack direction="row" alignItems="center" gap={1}>
<Controller
control={control}
name="currency"
rules={{ required: true }}
render={({ field: { value, onChange, onBlur } }) => (
<CurrencyAutocomplete
value={value}
onChange={onChange}
onBlur={onBlur}
error={!!errors.currency}
/>
)}
/>
<TextField
fullWidth
required
type="number"
label="Rate"
error={!!errors.close}
inputProps={{
step: 0.0001,
min: 0.0001,
}}
{...register('close', { required: true })}
/>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={onClose}>
Cancel
</Button>
<LoadingButton
type="submit"
variant="contained"
color="secondary"
loading={isCreating}
disabled={!isValid}
>
Save
</LoadingButton>
</DialogActions>
</form>
</Dialog>
);
}
1 change: 1 addition & 0 deletions src/components/CurrencyAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function CurrencyAutocomplete({
}: Props) {
return (
<Autocomplete
fullWidth
disableClearable
id="currency-autocomplete"
value={value}
Expand Down
45 changes: 38 additions & 7 deletions src/components/ExchangeRatesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TableCell from '@mui/material/TableCell';
import TableSortLabel from '@mui/material/TableSortLabel';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import flags from '@lib/flags';
import useFiltersFromUrl from '@lib/useFiltersFromUrl';
import useSortFromUrl from '@lib/useSortFromUrl';
import type { ExchangeRate } from '@server/exchangeRates/types';
import { formatAmount, formatDate } from '@lib/format';
import { formatDate } from '@lib/format';

export const DefaultSort = { id: 'ticker', desc: true };

Expand All @@ -33,10 +35,14 @@ const ExchangeRateTableRow = memo(ExchangeRateTableRowBase);

type Props = {
rates: ExchangeRate[];
onUpdateRate: (idx: number, rate: ExchangeRate) => void;
};

export default function ExchangeRatesTableTable({ rates }: Props) {
const table = useExchangeRatesTable(rates);
export default function ExchangeRatesTableTable({
rates,
onUpdateRate,
}: Props) {
const table = useExchangeRatesTable(rates, onUpdateRate);

return (
<Paper>
Expand All @@ -54,7 +60,10 @@ export default function ExchangeRatesTableTable({ rates }: Props) {
);
}

function useExchangeRatesTable(rates: ExchangeRate[]) {
function useExchangeRatesTable(
rates: ExchangeRate[],
onUpdateRate: Props['onUpdateRate'],
) {
const { sorting } = useSortFromUrl(DefaultSort);
const { filtersByField } = useFiltersFromUrl();

Expand Down Expand Up @@ -87,9 +96,31 @@ function useExchangeRatesTable(rates: ExchangeRate[]) {
columnHelper.accessor('close', {
header: 'Rate',
cell: (info) => (
<Typography fontSize="inherit">
{formatAmount(info.getValue(), info.row.original.code)}
</Typography>
<TextField
defaultValue={info.getValue() || 1.0}
type="number"
size="small"
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{info.row.original.code}
</InputAdornment>
),
},
}}
inputProps={{
step: 0.0001,
min: 0,
style: { textAlign: 'right' },
}}
onChange={(e) =>
onUpdateRate(info.row.index, {
...info.row.original,
close: parseFloat(e.target.value),
})
}
/>
),
meta: { numeric: true },
}),
Expand Down
82 changes: 78 additions & 4 deletions src/routes/ExchangeRatesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import SettingsIcon from '@mui/icons-material/Settings';
import CalculateIcon from '@mui/icons-material/Calculate';
import CurrencyExchangeIcon from '@mui/icons-material/CurrencyExchange';
import AddIcon from '@mui/icons-material/Add';
import SaveIcon from '@mui/icons-material/Save';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import { enqueueSnackbar } from 'notistack';
import { useFieldArray, useForm } from 'react-hook-form';
import { useCallback, useEffect } from 'react';
import client from '@lib/client';
import AppName from '@lib/appName';
import FullScreenSpinner from '@components/Layout/FullScreenSpinner';
Expand All @@ -19,6 +22,8 @@ import ExchangeRatesSettingsDialog from '@components/ExchangeRatesSettingsDialog
import { PolygonApiKeySettingName } from '@lib/getPolygonRates';
import ExchangeRateCalculatorDialog from '@components/ExchangeRateCalculatorDialog';
import RefreshRatesButton from '@components/RefreshRatesButton';
import type { ExchangeRate } from '@server/exchangeRates/types';
import CreateExchangeRateDialog from '@components/CreateExchangeRateDialog';

export default function ExchangeRatesPage() {
const { data: polygonApiKey } = client.getValue.useQuery({
Expand Down Expand Up @@ -51,12 +56,22 @@ export default function ExchangeRatesPage() {
onOpen: onCalculatorDialogOpen,
onClose: onCalculatorDialogClose,
} = useDialog();
const {
open: isUpdateDialogOpen,
onOpen: onUpdateDialogOpen,
onClose: onUpdateDialogClose,
} = useDialog();

const { handleUpdateRates, isUpdating, formRates, updateRate } =
useExchangeRatesForm(rates);

let content = null;
if (isLoading) {
content = <FullScreenSpinner />;
} else if (rates && rates.length > 0) {
content = <ExchangeRatesTable rates={rates || []} />;
content = (
<ExchangeRatesTable rates={formRates} onUpdateRate={updateRate} />
);
} else {
content = (
<EmptyState Icon={CurrencyExchangeIcon}>
Expand Down Expand Up @@ -88,6 +103,9 @@ export default function ExchangeRatesPage() {
<IconButton color="primary" disabled={!rates}>
<CalculateIcon onClick={onCalculatorDialogOpen} />
</IconButton>
<IconButton color="primary" disabled={isUpdating}>
<SaveIcon onClick={handleUpdateRates} />
</IconButton>
<RefreshRatesButton />
<IconButton color="primary">
<SettingsIcon onClick={onSettingsDialogOpen} />
Expand All @@ -104,17 +122,73 @@ export default function ExchangeRatesPage() {
onUpdate={updateValue}
/>
)}
{isCalculatorDialogOpen && rates && (
{isCalculatorDialogOpen && formRates && (
<ExchangeRateCalculatorDialog
open={isCalculatorDialogOpen}
onClose={onCalculatorDialogClose}
rates={rates}
rates={formRates}
/>
)}
{isUpdateDialogOpen && formRates && (
<CreateExchangeRateDialog
open={isUpdateDialogOpen}
onClose={onUpdateDialogClose}
/>
)}
</Stack>
<Fab aria-label="New rate">
<Fab aria-label="New rate" onClick={onUpdateDialogOpen}>
<AddIcon />
</Fab>
</>
);
}

type ExchangeRatesFormValues = {
rates: ExchangeRate[];
};

const useExchangeRatesForm = (rates: ExchangeRate[] | undefined) => {
const { mutate: updateExchangeRates, isPending: isUpdating } =
client.updateExchangeRates.useMutation({
onSuccess: () => {
enqueueSnackbar({
message: 'Exchange rates updated.',
variant: 'success',
});
},
onError: (e) => {
enqueueSnackbar({
message: `Failed to update exchange rates. ${e.message}`,
variant: 'error',
});
},
});

const { control } = useForm<ExchangeRatesFormValues>({
mode: 'onBlur',
defaultValues: {
rates: rates || [],
},
});
const {
fields: formRates,
update: updateRate,
replace: replaceRates,
} = useFieldArray({
control,
name: 'rates',
});

useEffect(() => replaceRates(rates || []), [rates, replaceRates]);

const handleUpdateRates = useCallback(() => {
updateExchangeRates(formRates);
}, [formRates, updateExchangeRates]);

return {
handleUpdateRates,
isUpdating,
formRates,
updateRate,
};
};
35 changes: 35 additions & 0 deletions src/server/exchangeRates/createExchangeRate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import currencyCodes from 'currency-codes';
import { type Procedure, procedure } from '@server/trpc';
import db from '@server/db';
import { CreateExchangeRateInput, CreateExchangeRateOutput } from './types';

const createExchangeRate: Procedure<
CreateExchangeRateInput,
CreateExchangeRateOutput
> = async ({ input: { ticker, close } }) => {
console.log('ticker', ticker);
console.log('close', close);
const inserted = await db
.insertInto('exchangeRate')
.values({
ticker,
close,
open: close,
low: close,
high: close,
date: new Date().toISOString(),
})
.returningAll()
.executeTakeFirstOrThrow();
const data = currencyCodes.code(inserted.ticker.replace('EUR', ''));
return {
...inserted,
code: data?.code || '',
currency: data?.currency || '',
};
};

export default procedure
.input(CreateExchangeRateInput)
.output(CreateExchangeRateOutput)
.mutation(createExchangeRate);
Loading