refactor: enforce frontend view model boundaries
This commit is contained in:
+1
@@ -10,6 +10,7 @@ type ErrorProps = Extract<AccountIncomePageViewModel, { state: "error" }>;
|
||||
|
||||
const AccountIncomePageError: FC<ErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
|
||||
|
||||
+41
-31
@@ -25,45 +25,46 @@ import { IncomeSummary } from "./IncomeSummary.view";
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
|
||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const {
|
||||
analytics,
|
||||
display,
|
||||
form,
|
||||
onReset,
|
||||
onSubmit,
|
||||
refreshError,
|
||||
refreshing,
|
||||
retry,
|
||||
} = props;
|
||||
const AccountIncomePageLoadedSummary: FC<LoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
function renderSummary() {
|
||||
if (props.partial) {
|
||||
return <Alert role="status">{t("investment.partial")}</Alert>;
|
||||
}
|
||||
if (analytics.totalsByCurrency.length > 0) {
|
||||
return <IncomeSummary totals={analytics.totalsByCurrency} />;
|
||||
}
|
||||
return (
|
||||
<StateView
|
||||
text={t("income.emptyText")}
|
||||
title={t("income.emptyTitle")}
|
||||
/>
|
||||
);
|
||||
|
||||
const { analytics } = props;
|
||||
|
||||
if (props.partial) {
|
||||
return <Alert role="status">{t("investment.partial")}</Alert>;
|
||||
}
|
||||
|
||||
return analytics.totalsByCurrency.length > 0
|
||||
? (
|
||||
<IncomeSummary totals={analytics.totalsByCurrency} />
|
||||
)
|
||||
: (
|
||||
<StateView text={t("income.emptyText")} title={t("income.emptyTitle")} />
|
||||
);
|
||||
};
|
||||
|
||||
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
const { display, form, onReset, onSubmit, refreshError, refreshing, retry }
|
||||
= props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
{!!(props.lastSuccessfulAt) && ((
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Title order={1}>{t("income.title")}</Title>
|
||||
|
||||
<Button disabled={refreshing} onClick={retry} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<IncomePeriodForm
|
||||
key={props.formRevision}
|
||||
@@ -71,13 +72,16 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
/>
|
||||
</FormProvider>
|
||||
{!!(refreshing) && ((
|
||||
|
||||
{!!refreshing && (
|
||||
<Group aria-label={t("income.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
|
||||
<Text>{t("income.refreshing")}</Text>
|
||||
</Group>
|
||||
))}
|
||||
{!!(refreshError) && ((
|
||||
)}
|
||||
|
||||
{!!refreshError && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
@@ -87,24 +91,30 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
||||
: "income.refreshError",
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Button onClick={retry} variant="default">
|
||||
{t("income.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
))}
|
||||
{renderSummary()}
|
||||
)}
|
||||
|
||||
<AccountIncomePageLoadedSummary {...props} />
|
||||
|
||||
<Alert color="blue">{t("income.taxBoundary")}</Alert>
|
||||
|
||||
<IncomeComparisons
|
||||
comparison={display.previousComparison}
|
||||
notice={t(`income.${display.previousNotice}`)}
|
||||
title={t("income.previousPeriod")}
|
||||
/>
|
||||
|
||||
<IncomeComparisons
|
||||
comparison={display.comparison}
|
||||
notice={t(`income.${display.yearNotice}`)}
|
||||
title={t("income.yearAgo")}
|
||||
/>
|
||||
|
||||
<IncomePayments entries={display.entries} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
+10
@@ -8,24 +8,34 @@ import classes from "../AccountIncomePage.module.css";
|
||||
|
||||
const AccountIncomePageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Stack gap="lg">
|
||||
<Skeleton height={32} width="40%" />
|
||||
|
||||
<Paper p="md" radius="md" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Skeleton height={20} width="20%" />
|
||||
|
||||
<div className={classes.fields}>
|
||||
<Skeleton height={60} />
|
||||
|
||||
<Skeleton height={60} />
|
||||
</div>
|
||||
|
||||
<Skeleton height={44} width="45%" />
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Skeleton height={100} radius="md" />
|
||||
|
||||
<Skeleton height={180} radius="md" />
|
||||
|
||||
<Skeleton height={24} width="25%" />
|
||||
|
||||
<Skeleton height={80} radius="md" />
|
||||
|
||||
<Skeleton height={80} radius="md" />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
|
||||
+44
-19
@@ -12,12 +12,16 @@ import classes from "../AccountIncomePage.module.css";
|
||||
type ComparisonProps = NonNullable<IncomeDisplayModel["comparison"]> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
type ComparisonValuesProps = {
|
||||
kind: "compared" | "difference" | "selected";
|
||||
kind: "compared"
|
||||
| "difference"
|
||||
| "selected";
|
||||
label: string;
|
||||
percentages?: ComparisonProps["values"][number]["percentages"];
|
||||
values: ComparisonProps["values"][number]["selected"];
|
||||
};
|
||||
|
||||
type IncomeComparisonsProps = {
|
||||
comparison: IncomeDisplayModel["comparison"];
|
||||
notice: string;
|
||||
@@ -28,6 +32,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{props.title}</Title>
|
||||
|
||||
{props.comparison
|
||||
? (
|
||||
<Comparison {...props.comparison} title={props.title} />
|
||||
@@ -45,6 +50,7 @@ const IncomeComparisons: FC<IncomeComparisonsProps> = (props) => {
|
||||
|
||||
const Comparison: FC<ComparisonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section aria-label={props.title} className={classes.comparison}>
|
||||
<Stack gap="md">
|
||||
@@ -61,11 +67,13 @@ const Comparison: FC<ComparisonProps> = (props) => {
|
||||
label={props.selectedPeriod}
|
||||
values={value.selected}
|
||||
/>
|
||||
|
||||
<ComparisonValues
|
||||
kind="compared"
|
||||
label={props.comparedPeriod}
|
||||
values={value.compared}
|
||||
/>
|
||||
|
||||
<ComparisonValues
|
||||
kind="difference"
|
||||
label={t("income.difference")}
|
||||
@@ -75,11 +83,12 @@ const Comparison: FC<ComparisonProps> = (props) => {
|
||||
</div>
|
||||
</Stack>
|
||||
))}
|
||||
{props.values.length === 0 && ((
|
||||
|
||||
{props.values.length === 0 && (
|
||||
<Text c="gray.7" size="sm">
|
||||
{t("income.comparisonEmpty")}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
</Stack>
|
||||
</section>
|
||||
);
|
||||
@@ -87,46 +96,59 @@ const Comparison: FC<ComparisonProps> = (props) => {
|
||||
|
||||
const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const data_tone
|
||||
= props.kind === "difference"
|
||||
? differenceTone(props.values.total.value)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={props.label}
|
||||
className={classes.comparisonPanel}
|
||||
data-kind={props.kind}
|
||||
data-tone={
|
||||
(props.kind === "difference") ? (differenceTone(props.values.total.value)) : undefined
|
||||
}
|
||||
data-tone={data_tone}
|
||||
>
|
||||
<Text className={classes.comparisonLabel} fw={600} size="sm">
|
||||
{props.label}
|
||||
</Text>
|
||||
|
||||
<dl className={classes.comparisonValues}>
|
||||
{(["total", "coupon", "dividend"] as const).map((key) => {
|
||||
const money = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.values[key];
|
||||
const percentage = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.percentages?.[key];
|
||||
const money
|
||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||
= props.values[key];
|
||||
|
||||
const percentage
|
||||
// eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name.
|
||||
= props.percentages?.[key];
|
||||
|
||||
const tone
|
||||
= props.kind === "difference"
|
||||
? differenceTone(money.value)
|
||||
: undefined;
|
||||
|
||||
const appearanceClass
|
||||
= key === "total"
|
||||
? classes.comparisonTotal
|
||||
: classes.comparisonDetail;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
key === "total"
|
||||
? classes.comparisonTotal
|
||||
: classes.comparisonDetail
|
||||
}
|
||||
key={key}
|
||||
>
|
||||
<div className={appearanceClass} key={key}>
|
||||
<dt>{t(`income.${key}`)}</dt>
|
||||
|
||||
<dd data-tone={tone}>
|
||||
<span>
|
||||
{tone === "positive" ? "+" : ""}
|
||||
|
||||
{formatMoney(money)}
|
||||
</span>
|
||||
{!!(percentage) && ((
|
||||
|
||||
{!!percentage && (
|
||||
<span className={classes.comparisonPercent}>
|
||||
{percentage}
|
||||
</span>
|
||||
))}
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
@@ -136,8 +158,11 @@ const ComparisonValues: FC<ComparisonValuesProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function differenceTone(value: string): "negative" | "neutral" | "positive" {
|
||||
function differenceTone(value: string): "negative"
|
||||
| "neutral"
|
||||
| "positive" {
|
||||
if (!/[1-9]/.test(value)) return "neutral";
|
||||
|
||||
return value.startsWith("-") ? "negative" : "positive";
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,16 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||
import classes from "../AccountIncomePage.module.css";
|
||||
|
||||
type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] };
|
||||
|
||||
type LoadedProps = Extract<AccountIncomePageViewModel, { state: "loaded" }>;
|
||||
|
||||
const IncomePayments: FC<IncomePaymentsProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack aria-label={t("income.payments")} component="section">
|
||||
<Title order={2}>{t("income.payments")}</Title>
|
||||
|
||||
<Stack className={classes.entries} component="ul" gap="sm">
|
||||
{props.entries.map(({ key, operation }) => (
|
||||
<li key={key}>
|
||||
|
||||
+10
-1
@@ -8,6 +8,7 @@ import type { IncomeForm } from "../../../../model/period";
|
||||
import { IncomePeriodForm } from "./IncomePeriodForm.view";
|
||||
|
||||
const period = { from: "01.08.2026", to: "27.08.2026" };
|
||||
|
||||
const errors = {
|
||||
to: { message: "Синтетическая ошибка периода", type: "manual" },
|
||||
};
|
||||
@@ -19,8 +20,13 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
|
||||
defaultValues: period,
|
||||
errors: props.invalid ? errors : undefined,
|
||||
});
|
||||
|
||||
const { isSubmitSuccessful } = useFormState({ control: methods.control });
|
||||
const submitted = isSubmitSuccessful ? methods.getValues() : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
|
||||
|
||||
const submitted = isSubmitSuccessful
|
||||
? methods.getValues()
|
||||
: null; // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<IncomePeriodForm
|
||||
@@ -29,6 +35,7 @@ const PeriodExample: FC<PeriodExampleProps> = (props) => {
|
||||
}}
|
||||
onSubmit={methods.handleSubmit(() => {})}
|
||||
/>
|
||||
|
||||
<output aria-live="polite">
|
||||
{submitted
|
||||
? `Применено: ${submitted.from} – ${submitted.to}`
|
||||
@@ -54,7 +61,9 @@ const meta = {
|
||||
} satisfies Meta<typeof PeriodExample>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const ValidationError: Story = {
|
||||
render: () => <PeriodExample invalid />,
|
||||
|
||||
+20
-38
@@ -2,17 +2,9 @@ import type { FC, SubmitEventHandler } from "react";
|
||||
|
||||
import { Button, Paper, Stack, Text } from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useState } from "react";
|
||||
import { useController, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatDraftDate,
|
||||
type IncomeForm,
|
||||
moscowDate,
|
||||
parseDraftDate,
|
||||
} from "../../../../model/period";
|
||||
import classes from "../../AccountIncomePage.module.css";
|
||||
import { useIncomePeriodFormVm } from "./IncomePeriodForm.vm";
|
||||
|
||||
type IncomePeriodFormProps = {
|
||||
onReset: () => void;
|
||||
@@ -20,23 +12,20 @@ type IncomePeriodFormProps = {
|
||||
};
|
||||
|
||||
const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext<IncomeForm>();
|
||||
const {
|
||||
field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef },
|
||||
fieldState: { error: fromError },
|
||||
} = useController({ control, name: "from" });
|
||||
const {
|
||||
field: { onBlur: onToBlur, onChange: onToChange },
|
||||
fieldState: { error: toError },
|
||||
} = useController({ control, name: "to" });
|
||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||
const [today, setToday] = useState(() => moscowDate(new Date()));
|
||||
const start = parseDraftDate(from);
|
||||
const end = parseDraftDate(to);
|
||||
end,
|
||||
fromError,
|
||||
fromRef,
|
||||
isSubmitting,
|
||||
onBlur2,
|
||||
onPeriodChange,
|
||||
popoverProps,
|
||||
start,
|
||||
t,
|
||||
today,
|
||||
toError,
|
||||
} = useIncomePeriodFormVm();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component="form"
|
||||
@@ -47,6 +36,7 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text fw={700}>{t("income.period")}</Text>
|
||||
|
||||
<div className={classes.fields}>
|
||||
<DatePickerInput
|
||||
allowSingleDateInRange
|
||||
@@ -56,29 +46,21 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
||||
error={fromError?.message ?? toError?.message}
|
||||
label={t("income.period")}
|
||||
maxDate={today}
|
||||
onBlur={() => {
|
||||
onFromBlur();
|
||||
onToBlur();
|
||||
}}
|
||||
onChange={([from, to]) => {
|
||||
onFromChange(from ? formatDraftDate(from) : "");
|
||||
onToChange(to ? formatDraftDate(to) : "");
|
||||
}}
|
||||
popoverProps={{
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
onOpen: () => setToday(moscowDate(new Date())),
|
||||
returnFocus: true,
|
||||
}}
|
||||
onBlur={onBlur2}
|
||||
onChange={onPeriodChange}
|
||||
popoverProps={popoverProps}
|
||||
ref={fromRef}
|
||||
type="range"
|
||||
value={[start, end]}
|
||||
valueFormat="DD.MM.YYYY"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
{t("income.apply")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={isSubmitting}
|
||||
onClick={props.onReset}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { useState } from "react";
|
||||
import { useController, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatDraftDate,
|
||||
type IncomeForm,
|
||||
moscowDate,
|
||||
parseDraftDate,
|
||||
} from "../../../../model/period";
|
||||
|
||||
function useIncomePeriodFormVm() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useFormContext<IncomeForm>();
|
||||
|
||||
const {
|
||||
field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef },
|
||||
fieldState: { error: fromError },
|
||||
} = useController({ control, name: "from" });
|
||||
|
||||
const {
|
||||
field: { onBlur: onToBlur, onChange: onToChange },
|
||||
fieldState: { error: toError },
|
||||
} = useController({ control, name: "to" });
|
||||
|
||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||
const [today, setToday] = useState(() => moscowDate(new Date()));
|
||||
const start = parseDraftDate(from);
|
||||
const end = parseDraftDate(to);
|
||||
|
||||
const onBlur2 = () => {
|
||||
onFromBlur();
|
||||
onToBlur();
|
||||
};
|
||||
|
||||
const onPeriodChange: (value: [null | string, null | string]) => void = ([
|
||||
from,
|
||||
to,
|
||||
]) => {
|
||||
onFromChange(from ? formatDraftDate(from) : "");
|
||||
onToChange(to ? formatDraftDate(to) : "");
|
||||
};
|
||||
|
||||
const popoverProps = {
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
onOpen: () => setToday(moscowDate(new Date())),
|
||||
returnFocus: true,
|
||||
};
|
||||
|
||||
return {
|
||||
end,
|
||||
fromError,
|
||||
fromRef,
|
||||
isSubmitting,
|
||||
onBlur2,
|
||||
onPeriodChange,
|
||||
popoverProps,
|
||||
start,
|
||||
t,
|
||||
today,
|
||||
toError,
|
||||
};
|
||||
}
|
||||
|
||||
export { useIncomePeriodFormVm };
|
||||
@@ -11,24 +11,18 @@ type IncomeSummaryProps = { totals: IncomeAnalytics["totalsByCurrency"] };
|
||||
|
||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>{t("income.summaryTitle")}</Title>
|
||||
|
||||
{props.totals.map(total => (
|
||||
<Paper key={total.currency} p="md" radius="md" withBorder>
|
||||
<Text fw={700}>{formatMoney(total.total)}</Text>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("income.coupon")}
|
||||
:
|
||||
{" "}
|
||||
{formatMoney(total.coupon)}
|
||||
{" "}
|
||||
·
|
||||
{" "}
|
||||
{t("income.dividend")}
|
||||
:
|
||||
{" "}
|
||||
{formatMoney(total.dividend)}
|
||||
{t("income.coupon")}: {formatMoney(total.coupon)} ·{" "}
|
||||
{t("income.dividend")}: {formatMoney(total.dividend)}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user