refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -33,6 +33,7 @@ function configureTestAuth() {
|
||||
|
||||
function renderPage(queryClient: QueryClient) {
|
||||
configureTestAuth();
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
@@ -50,6 +51,7 @@ describe("AccountsPage", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
|
||||
modes.push(new URL(request.url).searchParams.get("sync"));
|
||||
|
||||
return modes.length === 1
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(resource([account]));
|
||||
@@ -102,9 +104,11 @@ describe("AccountsPage", () => {
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json(resource([account]));
|
||||
|
||||
{
|
||||
const deferred = Promise.withResolvers<Response>();
|
||||
resolveRefresh = deferred.resolve;
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
}),
|
||||
@@ -131,6 +135,7 @@ describe("AccountsPage", () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
requests.push(await request.json());
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
@@ -142,11 +147,9 @@ describe("AccountsPage", () => {
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) return HttpResponse.json(resource([account]));
|
||||
|
||||
return requestCount === 2
|
||||
? HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
)
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(resource([account]));
|
||||
}),
|
||||
);
|
||||
@@ -161,6 +164,7 @@ describe("AccountsPage", () => {
|
||||
const warning = await screen.findByText(
|
||||
"Не удалось обновить данные. Показаны последние полученные.",
|
||||
);
|
||||
|
||||
expect(warning).toBeInTheDocument();
|
||||
expect(screen.getByText(account.name)).toBeInTheDocument();
|
||||
|
||||
@@ -176,12 +180,10 @@ describe("AccountsPage", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts", () => {
|
||||
requestCount += 1;
|
||||
|
||||
return requestCount === 1
|
||||
? HttpResponse.json(resource([]))
|
||||
: HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
|
||||
}),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
@@ -210,11 +212,13 @@ it("keeps unknown membership loading instead of declaring no accounts", async ()
|
||||
const client = createTestQueryClient();
|
||||
renderPage(client);
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject(
|
||||
{
|
||||
data: null,
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Нет инвестиционных счетов"),
|
||||
|
||||
@@ -20,6 +20,7 @@ const AccountsPage: FC = () => {
|
||||
sourceError={viewModel.sourceError}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountsPageLoaded
|
||||
accounts={viewModel.accounts}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { apiErrorCode } from "@/shared/api";
|
||||
function useAccountsPageViewModel() {
|
||||
const accounts = useBrokerageAccounts();
|
||||
const sync = useScreenSync({ screen: "accounts" }, [accounts]);
|
||||
|
||||
if (
|
||||
!accounts.data
|
||||
|| (accounts.meta?.availability === "partial" && accounts.data.length === 0)
|
||||
@@ -21,6 +22,7 @@ function useAccountsPageViewModel() {
|
||||
state: "error" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (!accounts.data?.length)
|
||||
return {
|
||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||
@@ -30,6 +32,7 @@ function useAccountsPageViewModel() {
|
||||
sourceError: sync.sourceError,
|
||||
state: "empty" as const,
|
||||
};
|
||||
|
||||
return {
|
||||
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
|
||||
const AccountsPageEmpty: FC<AccountsRefreshStateProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<AccountsRefreshState {...props} />
|
||||
|
||||
<StateView
|
||||
text={t("accounts.emptyText")}
|
||||
title={t("accounts.emptyTitle")}
|
||||
|
||||
@@ -12,11 +12,13 @@ type AccountsPageErrorProps = { code?: string; retry?: () => void };
|
||||
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||
const { code, retry } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const settingsAction = (
|
||||
<Button component={Link} to="/settings" variant="light">
|
||||
{t("accounts.openSettings")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected))
|
||||
return (
|
||||
<StateView
|
||||
@@ -41,6 +43,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||
action={(
|
||||
<Stack align="flex-start">
|
||||
<Button onClick={retry}>{t("accounts.retry")}</Button>
|
||||
|
||||
{settingsAction}
|
||||
</Stack>
|
||||
)}
|
||||
@@ -49,6 +52,7 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||
title={t("accounts.unavailableTitle")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||
|
||||
@@ -21,15 +21,19 @@ type AccountsPageLoadedProps = AccountsRefreshStateProps & {
|
||||
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
const { accounts, ...refresh } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack gap={4}>
|
||||
<Title order={1}>{t("accounts.title")}</Title>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<AccountsRefreshState {...refresh} />
|
||||
|
||||
<Button
|
||||
component={Link}
|
||||
to="/accounts/all"
|
||||
@@ -38,6 +42,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||
>
|
||||
{t("accounts.openAll")}
|
||||
</Button>
|
||||
|
||||
<BrokerageAccountList accounts={accounts} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -6,26 +6,35 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const AccountsPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="30%" />
|
||||
|
||||
<Skeleton height={20} width="45%" />
|
||||
|
||||
<SimpleGrid aria-hidden="true" cols={1} spacing="lg">
|
||||
{[0, 1].map(index => (
|
||||
<Card key={index} padding="xl" radius="lg" withBorder>
|
||||
<Stack gap="xl">
|
||||
<Stack gap="sm">
|
||||
<Skeleton height={24} width="35%" />
|
||||
|
||||
<Skeleton height={28} width="65%" />
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Skeleton height={16} width="30%" />
|
||||
|
||||
<Skeleton height={40} width="60%" />
|
||||
</Stack>
|
||||
|
||||
<Group grow>
|
||||
<Skeleton height={56} />
|
||||
|
||||
<Skeleton height={56} />
|
||||
</Group>
|
||||
|
||||
<Skeleton height={20} />
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -19,13 +19,14 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!(props.lastSuccessfulAt) && ((
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={refreshing}
|
||||
onClick={retry}
|
||||
@@ -34,15 +35,18 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
>
|
||||
{t(refreshError ? "accounts.retry" : "investment.refresh")}
|
||||
</Button>
|
||||
{!!(refreshing) && ((
|
||||
|
||||
{!!refreshing && (
|
||||
<Group aria-label={t("accounts.refreshing")} gap="xs" role="status">
|
||||
<Loader size="sm" />
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
{!!(refreshError) && ((
|
||||
)}
|
||||
|
||||
{!!refreshError && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
<Stack gap="sm">
|
||||
<Text>
|
||||
@@ -54,7 +58,7 @@ const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
))}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user