refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -31,12 +31,15 @@ import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
||||
const Navigation: FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<output aria-label="URL">{location.search}</output>
|
||||
|
||||
<button onClick={() => navigate(-1)} type="button">
|
||||
History back
|
||||
</button>
|
||||
|
||||
<button onClick={() => navigate(1)} type="button">
|
||||
History forward
|
||||
</button>
|
||||
@@ -55,10 +58,12 @@ describe("Upcoming event date filters", () => {
|
||||
server.use(
|
||||
http.all("*/api/v1/*", ({ request }) => {
|
||||
requests.push(request.url);
|
||||
|
||||
return new HttpResponse(null, { status: 503 });
|
||||
}),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -68,6 +73,7 @@ describe("Upcoming event date filters", () => {
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
expect(result.current).toEqual({ state: "loading" });
|
||||
expect(queryClient.isFetching()).toBe(0);
|
||||
@@ -91,20 +97,25 @@ describe("Upcoming event date filters", () => {
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
|
||||
requested.push(new URL(request.url).search);
|
||||
|
||||
return HttpResponse.json(
|
||||
resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
resource(
|
||||
/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
|
||||
null,
|
||||
{
|
||||
sync: {
|
||||
errorCode: "TBANK_UNAVAILABLE",
|
||||
id: null,
|
||||
state: "failed",
|
||||
},
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */)),
|
||||
},
|
||||
/* eslint-enable unicorn/no-null */
|
||||
),
|
||||
);
|
||||
}),
|
||||
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||
syncRequests.push(await request.json());
|
||||
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
@@ -113,6 +124,7 @@ describe("Upcoming event date filters", () => {
|
||||
);
|
||||
const period = Object.fromEntries(new URLSearchParams(search));
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAccountEventsPageViewModel("all"),
|
||||
{
|
||||
@@ -125,6 +137,7 @@ describe("Upcoming event date filters", () => {
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.state).toBe("error"));
|
||||
expect(requested).toEqual([`?${search}`]);
|
||||
expect(syncRequests).toEqual([]);
|
||||
@@ -153,6 +166,7 @@ describe("Upcoming event date filters", () => {
|
||||
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||
({ request }) => {
|
||||
searches.push(new URL(request.url).search);
|
||||
|
||||
return HttpResponse.json(resource({ events: [] }));
|
||||
},
|
||||
),
|
||||
@@ -171,6 +185,7 @@ describe("Upcoming event date filters", () => {
|
||||
initialIndex={0}
|
||||
>
|
||||
<Navigation />
|
||||
|
||||
<Routes>
|
||||
<Route
|
||||
element={<AccountEventsPage />}
|
||||
@@ -182,6 +197,7 @@ describe("Upcoming event date filters", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByLabelText("Период");
|
||||
|
||||
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "History forward" }),
|
||||
@@ -192,6 +208,7 @@ describe("Upcoming event date filters", () => {
|
||||
);
|
||||
await screen.findByLabelText("Период");
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History back" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("URL")).toHaveTextContent(
|
||||
@@ -227,6 +244,7 @@ describe("Upcoming event date filters", () => {
|
||||
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
|
||||
>
|
||||
<Navigation />
|
||||
|
||||
<Routes>
|
||||
<Route
|
||||
element={<AccountEventsPage />}
|
||||
@@ -258,6 +276,7 @@ describe("Upcoming event date filters", () => {
|
||||
"*/api/v1/brokerage-accounts/:accountId/events",
|
||||
({ request }) => {
|
||||
searches.push(new URL(request.url).search);
|
||||
|
||||
return HttpResponse.json(
|
||||
resource({
|
||||
accountId,
|
||||
@@ -278,6 +297,7 @@ describe("Upcoming event date filters", () => {
|
||||
]}
|
||||
>
|
||||
<Navigation />
|
||||
|
||||
<Routes>
|
||||
<Route
|
||||
element={<AccountEventsPage />}
|
||||
@@ -333,6 +353,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
|
||||
clear: vi.fn(),
|
||||
refresh: () => Promise.resolve(true),
|
||||
});
|
||||
|
||||
const events = [
|
||||
{
|
||||
date: "2026-10-01",
|
||||
@@ -341,16 +362,19 @@ it("retains the previous complete forecast and timestamp when its source refresh
|
||||
type: "coupon",
|
||||
},
|
||||
];
|
||||
|
||||
const snapshot = resource(
|
||||
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
|
||||
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } },
|
||||
);
|
||||
|
||||
server.use(
|
||||
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
|
||||
HttpResponse.json(snapshot),
|
||||
),
|
||||
);
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
@@ -358,6 +382,7 @@ it("retains the previous complete forecast and timestamp when its source refresh
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current).toMatchObject({
|
||||
events,
|
||||
|
||||
@@ -37,7 +37,8 @@ function renderPage() {
|
||||
describe("AccountEventsPage", () => {
|
||||
beforeEach(() => {
|
||||
refresh.mockClear();
|
||||
query.mockReturnValue((/* 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. */
|
||||
query.mockReturnValue(
|
||||
{
|
||||
data: {
|
||||
accountId: "account",
|
||||
@@ -51,8 +52,8 @@ describe("AccountEventsPage", () => {
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */) as never);
|
||||
} as /* eslint-enable unicorn/no-null */ never,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the selected route account to the query", () => {
|
||||
@@ -72,7 +73,8 @@ describe("AccountEventsPage", () => {
|
||||
});
|
||||
|
||||
it("keeps the loading fallback when no result or account is available", () => {
|
||||
query.mockReturnValue((/* 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. */
|
||||
query.mockReturnValue(
|
||||
{
|
||||
data: undefined,
|
||||
isError: false,
|
||||
@@ -82,8 +84,8 @@ describe("AccountEventsPage", () => {
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */) as never);
|
||||
} as /* eslint-enable unicorn/no-null */ never,
|
||||
);
|
||||
renderPage();
|
||||
expect(query).toHaveBeenCalledWith("", {});
|
||||
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||
@@ -93,7 +95,8 @@ describe("AccountEventsPage", () => {
|
||||
});
|
||||
|
||||
it("renders events and marks an offer without an amount", () => {
|
||||
query.mockReturnValue((/* 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. */
|
||||
query.mockReturnValue(
|
||||
{
|
||||
data: {
|
||||
accountId: "account",
|
||||
@@ -132,8 +135,8 @@ describe("AccountEventsPage", () => {
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */) as never);
|
||||
} as /* eslint-enable unicorn/no-null */ never,
|
||||
);
|
||||
renderPage();
|
||||
expect(screen.getByText("1 250 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
|
||||
@@ -180,7 +183,8 @@ describe("AccountEventsPage", () => {
|
||||
});
|
||||
|
||||
it("shows a structural busy state before events are available", () => {
|
||||
query.mockReturnValue((/* 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. */
|
||||
query.mockReturnValue(
|
||||
{
|
||||
data: undefined,
|
||||
isError: false,
|
||||
@@ -190,8 +194,8 @@ describe("AccountEventsPage", () => {
|
||||
sync: { errorCode: null, id: null, state: "idle" },
|
||||
},
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
/* eslint-enable unicorn/no-null */) as never);
|
||||
} as /* eslint-enable unicorn/no-null */ never,
|
||||
);
|
||||
|
||||
renderPage();
|
||||
|
||||
|
||||
@@ -10,7 +10,14 @@ type AccountEventsPageProps = { accountId?: string };
|
||||
const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
|
||||
const viewModel = useAccountEventsPageViewModel(props.accountId);
|
||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
||||
return viewModel.state === "error" ? <AccountEventsPageError {...viewModel} /> : <AccountEventsPageLoaded {...viewModel} />;
|
||||
|
||||
return viewModel.state === "error"
|
||||
? (
|
||||
<AccountEventsPageError {...viewModel} />
|
||||
)
|
||||
: (
|
||||
<AccountEventsPageLoaded {...viewModel} />
|
||||
);
|
||||
};
|
||||
|
||||
export { AccountEventsPage };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
type BrokerageEvent,
|
||||
useBrokerageEvents,
|
||||
} from "@/entities/brokerage-event";
|
||||
import { useScreenSync } from "@/features/tbank-connection";
|
||||
import { isValidPeriod } from "@/shared/lib";
|
||||
|
||||
type AccountEventsPageViewModel
|
||||
= | {
|
||||
@@ -38,8 +38,15 @@ function useAccountEventsPageViewModel(
|
||||
const urlFrom = searchParameters.get("from");
|
||||
const urlTo = searchParameters.get("to");
|
||||
const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo);
|
||||
const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
|
||||
const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null;
|
||||
|
||||
const appliedFrom = isValidUrlPeriod
|
||||
? urlFrom
|
||||
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
|
||||
const appliedTo = isValidUrlPeriod
|
||||
? urlTo
|
||||
: null; // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model.
|
||||
|
||||
const [draftFrom, setDraftFrom] = useState<null | string>(appliedFrom);
|
||||
const [draftTo, setDraftTo] = useState<null | string>(appliedTo);
|
||||
useEffect(() => {
|
||||
@@ -49,19 +56,23 @@ function useAccountEventsPageViewModel(
|
||||
normalized.delete("to");
|
||||
setSearchParameters(normalized, { replace: true });
|
||||
}, [isValidUrlPeriod, searchParameters, setSearchParameters]);
|
||||
|
||||
const [previousPeriod, setPreviousPeriod] = useState([
|
||||
appliedFrom,
|
||||
appliedTo,
|
||||
]);
|
||||
|
||||
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
|
||||
setPreviousPeriod([appliedFrom, appliedTo]);
|
||||
setDraftFrom(appliedFrom);
|
||||
setDraftTo(appliedTo);
|
||||
}
|
||||
|
||||
const events = useBrokerageEvents(accountId, {
|
||||
...(appliedFrom && { from: appliedFrom }),
|
||||
...(appliedTo && { to: appliedTo }),
|
||||
});
|
||||
|
||||
const sync = useScreenSync(
|
||||
{
|
||||
accountId,
|
||||
@@ -71,12 +82,15 @@ function useAccountEventsPageViewModel(
|
||||
},
|
||||
[events],
|
||||
);
|
||||
|
||||
if (events.isLoading) return { state: "loading" };
|
||||
|
||||
const onReset = () => {
|
||||
setDraftFrom(null);
|
||||
setDraftTo(null);
|
||||
setSearchParameters({});
|
||||
};
|
||||
|
||||
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
|
||||
return {
|
||||
onReset,
|
||||
@@ -86,6 +100,7 @@ function useAccountEventsPageViewModel(
|
||||
state: "error",
|
||||
};
|
||||
if (!events.data) return { state: "loading" };
|
||||
|
||||
return {
|
||||
canApply:
|
||||
(draftFrom !== appliedFrom || draftTo !== appliedTo)
|
||||
@@ -115,14 +130,4 @@ function useAccountEventsPageViewModel(
|
||||
};
|
||||
}
|
||||
|
||||
const calendarDate = z.iso.date();
|
||||
function isValidPeriod(from: null | string, to: null | string) {
|
||||
return (
|
||||
[from, to].every(
|
||||
date => date === null || calendarDate.safeParse(date).success,
|
||||
)
|
||||
&& (!from || !to || from <= to)
|
||||
);
|
||||
}
|
||||
|
||||
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const AccountEventsPageEmpty: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StateView text={t("events.emptyText")} title={t("events.emptyTitle")} />
|
||||
);
|
||||
|
||||
+2
@@ -8,11 +8,13 @@ type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void };
|
||||
|
||||
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<StateView
|
||||
action={(
|
||||
<Group>
|
||||
<Button onClick={props.retry}>{t("events.retry")}</Button>
|
||||
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
|
||||
+55
-41
@@ -10,7 +10,6 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { DatePickerInput } from "@mantine/dates";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BrokerageEventCard } from "@/entities/brokerage-event";
|
||||
import { formatSnapshotTime } from "@/shared/lib";
|
||||
@@ -18,82 +17,94 @@ import { formatSnapshotTime } from "@/shared/lib";
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
|
||||
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
|
||||
import { useAccountEventsPageLoadedVm } from "./AccountEventsPageLoaded.vm";
|
||||
|
||||
type AccountEventsPageLoadedProps = Extract<
|
||||
AccountEventsPageViewModel,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
|
||||
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
function renderEvents() {
|
||||
if (props.events.length > 0) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{props.events.map(event => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.account?.id,
|
||||
event.type,
|
||||
event.date,
|
||||
event.instrumentUid,
|
||||
event.figi,
|
||||
event.ticker,
|
||||
event.logoName,
|
||||
])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (props.partial) {
|
||||
return;
|
||||
}
|
||||
return <AccountEventsPageEmpty />;
|
||||
const AccountEventsPageLoadedEvents: FC<AccountEventsPageLoadedProps> = (
|
||||
props,
|
||||
) => {
|
||||
if (props.events.length > 0) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{props.events.map(event => (
|
||||
<BrokerageEventCard
|
||||
event={event}
|
||||
key={JSON.stringify([
|
||||
event.account?.id,
|
||||
event.type,
|
||||
event.date,
|
||||
event.instrumentUid,
|
||||
event.figi,
|
||||
event.ticker,
|
||||
event.logoName,
|
||||
])}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.partial) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <AccountEventsPageEmpty />;
|
||||
};
|
||||
|
||||
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
const { onPeriodChange, t } = useAccountEventsPageLoadedVm(props);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{!!(props.lastSuccessfulAt) && ((
|
||||
{!!props.lastSuccessfulAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("investment.sourceTime", {
|
||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||
})}
|
||||
</Text>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Title order={1}>{t("events.title")}</Title>
|
||||
|
||||
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
{!!(props.partial) && ((
|
||||
|
||||
{!!props.partial && (
|
||||
<Alert role="status">{t("investment.partial")}</Alert>
|
||||
))}
|
||||
{!!(props.refreshing) && ((
|
||||
)}
|
||||
|
||||
{!!props.refreshing && (
|
||||
<Progress animated aria-label={t("accounts.refreshing")} value={100} />
|
||||
))}
|
||||
{!!(props.refreshError) && ((
|
||||
)}
|
||||
|
||||
{!!props.refreshError && (
|
||||
<Alert aria-live="polite" color="yellow" role="status">
|
||||
{t(
|
||||
props.sourceError
|
||||
? "investment.syncFailed"
|
||||
: "accounts.refreshError",
|
||||
)}
|
||||
|
||||
<Button onClick={props.retry} variant="subtle">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
))}
|
||||
)}
|
||||
|
||||
<Text c="dimmed">{t("events.hint")}</Text>
|
||||
|
||||
<Group align="end">
|
||||
<DatePickerInput
|
||||
allowSingleDateInRange
|
||||
clearable
|
||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||
label={t("ledger.period")}
|
||||
onChange={([from, to]) => {
|
||||
props.onDraftFromChange(from);
|
||||
props.onDraftToChange(to);
|
||||
}}
|
||||
onChange={onPeriodChange}
|
||||
popoverProps={{
|
||||
middlewares: { shift: { crossAxis: true } },
|
||||
returnFocus: true,
|
||||
@@ -103,15 +114,18 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||
valueFormat="DD.MM.YYYY"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button disabled={!props.canApply} onClick={props.onApply}>
|
||||
{t("ledger.apply")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={props.onReset} variant="default">
|
||||
{t("ledger.reset")}
|
||||
</Button>
|
||||
</Group>
|
||||
{renderEvents()}
|
||||
|
||||
<AccountEventsPageLoadedEvents {...props} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||
|
||||
type AccountEventsPageLoadedProps = Extract<
|
||||
AccountEventsPageViewModel,
|
||||
{ state: "loaded" }
|
||||
>;
|
||||
|
||||
function useAccountEventsPageLoadedVm(props: AccountEventsPageLoadedProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onPeriodChange: (value: [null | string, null | string]) => void = ([
|
||||
from,
|
||||
to,
|
||||
]) => {
|
||||
props.onDraftFromChange(from);
|
||||
props.onDraftToChange(to);
|
||||
};
|
||||
|
||||
return { onPeriodChange, t };
|
||||
}
|
||||
|
||||
export { useAccountEventsPageLoadedVm };
|
||||
+7
@@ -8,18 +8,25 @@ const skeletonRowIds = ["first", "second", "third", "fourth"];
|
||||
|
||||
const AccountEventsPageLoading: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<LoadingSkeleton label={t("states.loadingTitle")}>
|
||||
<Skeleton height={32} width="40%" />
|
||||
|
||||
<Skeleton height={18} width="70%" />
|
||||
|
||||
<Group>
|
||||
<Skeleton height={60} width={220} />
|
||||
|
||||
<Skeleton height={60} width={220} />
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Skeleton height={36} width={116} />
|
||||
|
||||
<Skeleton height={36} width={108} />
|
||||
</Group>
|
||||
|
||||
{skeletonRowIds.map(id => (
|
||||
<Skeleton height={82} key={id} radius="md" />
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user