refactor: simplify frontend state and mutation boundaries
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s
Frontend verify / commits (pull_request) Successful in 31s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 9m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 9m59s
This commit was merged in pull request #44.
This commit is contained in:
@@ -102,6 +102,24 @@ const portfolio: Portfolio
|
||||
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
|
||||
};
|
||||
|
||||
function position(
|
||||
overrides: Partial<Portfolio["positions"][number]>,
|
||||
): Portfolio["positions"][number] {
|
||||
return {
|
||||
absoluteReturn: null,
|
||||
averagePurchasePrice: null,
|
||||
currentValue: null,
|
||||
displayName: "Инструмент",
|
||||
figi: null,
|
||||
isin: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const emptyRecentOperations: AccountPageRecentOperations = {
|
||||
state: "empty",
|
||||
to: "/accounts/account-1/operations",
|
||||
@@ -474,242 +492,106 @@ describe("AccountPage states", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders stock cards with logo fallback and signed unlabeled return", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps(
|
||||
{
|
||||
it.each(["STOCK", "BOND"] as const)(
|
||||
"renders %s logos, fallbacks and signed returns including zero",
|
||||
(type) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
position({
|
||||
absoluteReturn: { currency: "RUB", value: "3" },
|
||||
averagePurchasePrice: null,
|
||||
currentValue: { currency: "RUB", value: "5" },
|
||||
displayName: "Абра",
|
||||
figi: "FIGI-A",
|
||||
isin: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
{
|
||||
displayName: "С логотипом",
|
||||
logoName: "dolg.png",
|
||||
type,
|
||||
}),
|
||||
position({
|
||||
absoluteReturn: { currency: "RUB", value: "2" },
|
||||
displayName: "Без логотипа",
|
||||
type,
|
||||
}),
|
||||
position({
|
||||
absoluteReturn: { currency: "RUB", value: "0" },
|
||||
averagePurchasePrice: null,
|
||||
currentValue: { currency: "RUB", value: "7" },
|
||||
displayName: "Кадабра",
|
||||
figi: "FIGI-B",
|
||||
isin: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
displayName: "Нулевой доход",
|
||||
type,
|
||||
}),
|
||||
],
|
||||
},
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("А")).toBeInTheDocument();
|
||||
expect(screen.getByText("+3 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("0 ₽")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "Абра" })).not.toBeInTheDocument();
|
||||
});
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "С логотипом" })).toHaveAttribute(
|
||||
"src", "https://invest-brands.cdn-tinkoff.ru/dolgx160.png",
|
||||
);
|
||||
expect(screen.getByText("Б")).toBeInTheDocument();
|
||||
expect(screen.getByText("Н")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("img")).toHaveLength(1);
|
||||
for (const value of ["+3 ₽", "+2 ₽", "0 ₽"])
|
||||
expect(screen.getByText(value)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("groups stock supporting metrics into quiet single lines", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps(
|
||||
{
|
||||
it.each(["STOCK", "BOND"] as const)(
|
||||
"groups %s supporting metrics into quiet single lines",
|
||||
(type) => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
absoluteReturn: { currency: "RUB", value: "3" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "11" },
|
||||
currentValue: { currency: "RUB", value: "10" },
|
||||
displayName: "Тихая акция",
|
||||
figi: "FIGI-QUIET",
|
||||
isin: "RU000QUIET1",
|
||||
logoName: null,
|
||||
quantity: "2",
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Количество: 2 · RU000QUIET1")).not.toHaveAttribute(
|
||||
"data-line-clamp",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
positions: [position({
|
||||
averagePurchasePrice: { currency: "RUB", value: "11" },
|
||||
currentValue: { currency: "RUB", value: "10" },
|
||||
isin: "RU000QUIET1",
|
||||
quantity: "2",
|
||||
type,
|
||||
})],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Количество: 2 · RU000QUIET1"))
|
||||
.not.toHaveAttribute("data-line-clamp");
|
||||
expect(screen.getByText("Средняя цена: 11 ₽ · Доля: 100 %"))
|
||||
.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps(
|
||||
{
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
absoluteReturn: null,
|
||||
averagePurchasePrice: null,
|
||||
currentValue: null,
|
||||
displayName: "Акция с тикером",
|
||||
figi: "FIGI-STOCK",
|
||||
isin: "STOCK-ISIN",
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
ticker: "SBER",
|
||||
type: "STOCK",
|
||||
},
|
||||
{
|
||||
absoluteReturn: null,
|
||||
averagePurchasePrice: null,
|
||||
currentValue: null,
|
||||
displayName: "Акция без тикера",
|
||||
figi: "FIGI-FALLBACK",
|
||||
isin: "FALLBACK-ISIN",
|
||||
logoName: null,
|
||||
quantity: "2",
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
{
|
||||
absoluteReturn: null,
|
||||
averagePurchasePrice: null,
|
||||
currentValue: null,
|
||||
displayName: "Облигация",
|
||||
figi: "FIGI-BOND",
|
||||
isin: "BOND-ISIN",
|
||||
logoName: null,
|
||||
quantity: "3",
|
||||
ticker: "OFZ",
|
||||
type: "BOND",
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [
|
||||
position({ isin: "STOCK-ISIN", ticker: "SBER" }),
|
||||
position({ isin: "FALLBACK-ISIN", quantity: "2" }),
|
||||
position({
|
||||
isin: "BOND-ISIN", quantity: "3", ticker: "OFZ", type: "BOND",
|
||||
}),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Количество: 1 · SBER")).toBeInTheDocument();
|
||||
expect(screen.queryByText("STOCK-ISIN")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Количество: 2 · FALLBACK-ISIN"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Количество: 3 · BOND-ISIN")).toBeInTheDocument();
|
||||
expect(screen.getByText("Количество: 2 · FALLBACK-ISIN"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("Количество: 3 · BOND-ISIN"))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByText("OFZ")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps(
|
||||
{
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
absoluteReturn: { currency: "RUB", value: "2" },
|
||||
averagePurchasePrice: null,
|
||||
currentValue: { currency: "RUB", value: "50" },
|
||||
displayName: "Долг",
|
||||
figi: "FIGI-C",
|
||||
isin: "RU000C0XY1Z3",
|
||||
logoName: "dolg.png",
|
||||
quantity: "5",
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
{
|
||||
absoluteReturn: { currency: "RUB", value: "0" },
|
||||
averagePurchasePrice: null,
|
||||
currentValue: null,
|
||||
displayName: "Купон",
|
||||
figi: "FIGI-D",
|
||||
isin: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute(
|
||||
"src",
|
||||
"https://invest-brands.cdn-tinkoff.ru/dolgx160.png",
|
||||
);
|
||||
expect(screen.getByText("К")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("img", { name: "Купон" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("+2 ₽")).toBeInTheDocument();
|
||||
expect(screen.getByText("0 ₽")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Доход:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders bond cards in expanded layout with bond-specific classes", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps(
|
||||
{
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
absoluteReturn: { currency: "RUB", value: "50" },
|
||||
averagePurchasePrice: { currency: "RUB", value: "950" },
|
||||
currentValue: { currency: "RUB", value: "1000" },
|
||||
displayName: "Облигация 2030",
|
||||
figi: "FIGI-BOND",
|
||||
isin: "RU000B0ND123",
|
||||
logoName: null,
|
||||
quantity: "10",
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("О")).toBeInTheDocument();
|
||||
expect(screen.getByText("+50 ₽")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Средняя цена: 950 ₽ · Доля: 100 %"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||
renderPage(
|
||||
<AccountPageLoaded
|
||||
{...loadedProps(
|
||||
{
|
||||
...portfolio,
|
||||
positions: [
|
||||
{
|
||||
absoluteReturn: { currency: "RUB", value: "-5" },
|
||||
averagePurchasePrice: null,
|
||||
currentValue: { currency: "RUB", value: "30" },
|
||||
displayName: "Фонд",
|
||||
figi: "FIGI-E",
|
||||
isin: null,
|
||||
logoName: "fond.png",
|
||||
quantity: "3",
|
||||
ticker: null,
|
||||
type: "FUND",
|
||||
},
|
||||
],
|
||||
},
|
||||
)}
|
||||
{...loadedProps({
|
||||
...portfolio,
|
||||
positions: [position({
|
||||
absoluteReturn: { currency: "RUB", value: "-5" },
|
||||
displayName: "Фонд",
|
||||
logoName: "fond.png",
|
||||
type: "FUND",
|
||||
})],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument();
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
import type { BondsPageVm } from "./BondsPage.vm";
|
||||
|
||||
import classes from "./BondsPage.module.css";
|
||||
import { useBondsPageViewVm } from "./BondsPageView.vm";
|
||||
|
||||
type BondsPageViewProps = { vm: BondsPageVm };
|
||||
|
||||
@@ -166,7 +165,8 @@ const BondsPageViewResults: FC<BondsPageViewProps> = (props) => {
|
||||
};
|
||||
|
||||
const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
||||
const { onChange, t, vm } = useBondsPageViewVm(props);
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -191,7 +191,7 @@ const BondsPageView: FC<BondsPageViewProps> = (props) => {
|
||||
<TextInput
|
||||
label={t("bond.searchLabel")}
|
||||
maxLength={80}
|
||||
onChange={onChange}
|
||||
onChange={vm.onQueryChange}
|
||||
placeholder={t("bond.searchPlaceholder")}
|
||||
size="md"
|
||||
value={vm.query}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
@@ -35,6 +37,10 @@ function useBondsPageVm() {
|
||||
|| (!result.data
|
||||
&& !result.isError
|
||||
&& result.meta?.sync.state !== "failed")),
|
||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const q = event.currentTarget.value;
|
||||
setParameters(q ? { q } : {}, { replace: true });
|
||||
},
|
||||
partial: result.meta?.availability === "partial",
|
||||
query,
|
||||
ready:
|
||||
@@ -45,7 +51,6 @@ function useBondsPageVm() {
|
||||
retry: () => {
|
||||
void sync.retry();
|
||||
},
|
||||
setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { BondsPageVm } from "./BondsPage.vm";
|
||||
|
||||
type BondsPageViewProps = { vm: BondsPageVm };
|
||||
|
||||
function useBondsPageViewVm(props: BondsPageViewProps) {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChange: ComponentProps<typeof TextInput>["onChange"] = event =>
|
||||
vm.setQuery(event.currentTarget.value);
|
||||
|
||||
return { onChange, t, vm };
|
||||
}
|
||||
|
||||
export { useBondsPageViewVm };
|
||||
@@ -126,6 +126,32 @@ function setup(
|
||||
}
|
||||
|
||||
describe("CardPage", () => {
|
||||
it.each([
|
||||
["Изменить карту", "Изменить карту"],
|
||||
["Добавить операцию", "Добавить операцию"],
|
||||
["Изменить", "Изменить операцию"],
|
||||
["Удалить", "Удалить операцию?"],
|
||||
])("opens %s and restores focus after cancellation", async (action, title) => {
|
||||
setup();
|
||||
server.use(
|
||||
http.get("*/api/v1/bank-cards/categories", () =>
|
||||
HttpResponse.json([]),
|
||||
),
|
||||
http.get("*/api/v1/bank-cards/operations/:id", ({ params }) =>
|
||||
HttpResponse.json({ ...operation, id: params.id }),
|
||||
),
|
||||
);
|
||||
await screen.findByText("Перевод на «Запасная»");
|
||||
const trigger = screen.getByRole("button", { name: action });
|
||||
await waitFor(() => expect(trigger).toBeEnabled());
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
const dialog = await screen.findByRole("dialog", { name: title });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Отмена" }));
|
||||
await waitFor(() => expect(dialog).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it.each([
|
||||
["0001-01", "0001-01-31"],
|
||||
["0099-12", "0099-12-31"],
|
||||
|
||||
@@ -14,14 +14,14 @@ const CardPage: FC = () => {
|
||||
<>
|
||||
<CardPageView vm={vm} />
|
||||
|
||||
{vm.editingCard && vm.card.data && (
|
||||
<BankCardForm card={vm.card.data} onClose={vm.closeEditing} />
|
||||
{vm.editingCard && vm.card && (
|
||||
<BankCardForm card={vm.card} onClose={vm.closeEditing} />
|
||||
)}
|
||||
|
||||
{vm.operation && vm.card.data && vm.cards.data && (
|
||||
{vm.operation && vm.card && vm.cards && (
|
||||
<OperationForm
|
||||
card={vm.card.data}
|
||||
cards={vm.cards.data}
|
||||
card={vm.card}
|
||||
cards={vm.cards}
|
||||
onClose={vm.closeOperation}
|
||||
operation={vm.editableOperation}
|
||||
/>
|
||||
|
||||
@@ -41,7 +41,7 @@ type CardPageViewProps = { vm: CardPageVm };
|
||||
const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card.data;
|
||||
const card = vm.card;
|
||||
|
||||
if (!card) return;
|
||||
|
||||
@@ -70,11 +70,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
aria-label={t("bank.edit")}
|
||||
disabled={
|
||||
vm.history.isPlaceholderData
|
||||
|| !row.editable
|
||||
|| Boolean(card.archivedAt)
|
||||
}
|
||||
disabled={!row.editable}
|
||||
onClick={row.onEdit}
|
||||
size="sm"
|
||||
title={t("bank.edit")}
|
||||
@@ -86,11 +82,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
<ActionIcon
|
||||
aria-label={t("bank.delete")}
|
||||
color="red"
|
||||
disabled={
|
||||
vm.history.isPlaceholderData
|
||||
|| !row.editable
|
||||
|| Boolean(card.archivedAt)
|
||||
}
|
||||
disabled={!row.editable}
|
||||
onClick={row.onDelete}
|
||||
size="sm"
|
||||
title={t("bank.delete")}
|
||||
@@ -125,7 +117,7 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
||||
return <Text>{t("bank.noOperations")}</Text>;
|
||||
}
|
||||
|
||||
if (vm.history.isLoading) {
|
||||
if (vm.history.loading) {
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loadingHistory")}>
|
||||
<Stack>
|
||||
@@ -142,24 +134,18 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
|
||||
const {
|
||||
archiveActionLabel,
|
||||
card,
|
||||
getControlProps,
|
||||
getItemProps,
|
||||
onAddOperation,
|
||||
onEditCard,
|
||||
onPageChange,
|
||||
onToggleArchive,
|
||||
paginationRef,
|
||||
retryCard,
|
||||
retryCards,
|
||||
retryHistory,
|
||||
retrySummary,
|
||||
t,
|
||||
} = useCardPagePresentationVm(vm);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card;
|
||||
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
|
||||
|
||||
if (!card) {
|
||||
if (vm.card.isLoading)
|
||||
if (vm.cardLoading)
|
||||
return (
|
||||
<LoadingSkeleton label={t("bank.loading")}>
|
||||
<Stack>
|
||||
@@ -184,7 +170,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
{t("bank.back")}
|
||||
</Button>
|
||||
|
||||
{!vm.notFound && <Button onClick={retryCard}>{t("bank.retry")}</Button>}
|
||||
{!vm.notFound && <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>}
|
||||
</Group>
|
||||
)}
|
||||
error
|
||||
@@ -207,7 +193,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
<Group className={classes.cardActions} gap={4} wrap="nowrap">
|
||||
<ActionIcon
|
||||
aria-label={t("bank.editCard")}
|
||||
onClick={onEditCard}
|
||||
onClick={vm.openCardEditor}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={t("bank.editCard")}
|
||||
@@ -220,7 +206,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
aria-label={archiveActionLabel}
|
||||
color="red"
|
||||
loading={vm.archivePending}
|
||||
onClick={onToggleArchive}
|
||||
onClick={vm.toggleArchive}
|
||||
radius="md"
|
||||
size="md"
|
||||
title={archiveActionLabel}
|
||||
@@ -234,40 +220,33 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
</div>
|
||||
|
||||
{!!vm.archiveError && (
|
||||
<BankFeedback error={vm.archiveError} retry={retryCard} />
|
||||
<BankFeedback error={vm.archiveError} retry={vm.retryCard} />
|
||||
)}
|
||||
|
||||
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
|
||||
</Stack>
|
||||
|
||||
{vm.card.isError && (
|
||||
{vm.cardRefreshError && (
|
||||
<Group role="alert">
|
||||
<Text>{t("bank.refreshError")}</Text>
|
||||
|
||||
<Button onClick={retryCard}>{t("bank.retry")}</Button>
|
||||
<Button onClick={vm.retryCard}>{t("bank.retry")}</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vm.card.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
|
||||
{vm.cardRefreshing && <Text role="status">{t("bank.refreshing")}</Text>}
|
||||
|
||||
{vm.cards.isError && (
|
||||
{vm.cardsFeedback && (
|
||||
<BankFeedback
|
||||
cached={Boolean(vm.cards.data)}
|
||||
error={vm.cards.error}
|
||||
{...vm.cardsFeedback}
|
||||
loadingError
|
||||
retry={retryCards}
|
||||
retry={vm.retryCards}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
|
||||
|
||||
<BankSummaryView
|
||||
data={vm.summary.data}
|
||||
error={vm.summary.isError}
|
||||
fetching={vm.summary.isFetching}
|
||||
loading={vm.summary.isLoading}
|
||||
retry={retrySummary}
|
||||
/>
|
||||
<BankSummaryView {...vm.summary} />
|
||||
|
||||
<Group align="end" justify="space-between">
|
||||
<Group gap="xs">
|
||||
@@ -275,8 +254,8 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
|
||||
<ActionIcon
|
||||
aria-label={t("bank.addOperation")}
|
||||
disabled={Boolean(card.archivedAt) || !vm.cards.data}
|
||||
onClick={onAddOperation}
|
||||
disabled={!vm.canAddOperation}
|
||||
onClick={vm.openNewOperation}
|
||||
size="lg"
|
||||
title={t("bank.addOperation")}
|
||||
variant="subtle"
|
||||
@@ -303,24 +282,24 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
</Group>
|
||||
|
||||
<Stack
|
||||
aria-busy={vm.history.isFetching}
|
||||
aria-busy={vm.history.refreshing}
|
||||
className={classes.operations}
|
||||
data-pending={vm.history.isPlaceholderData || undefined}
|
||||
data-pending={vm.history.changingPage || undefined}
|
||||
gap="lg"
|
||||
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.data?.page ?? "loading"}`}
|
||||
key={`${vm.period.from}-${vm.period.to}-${vm.pageSize}-${vm.history.page ?? "loading"}`}
|
||||
>
|
||||
<CardPageViewHistory {...props} />
|
||||
</Stack>
|
||||
|
||||
{vm.history.isError && (
|
||||
{vm.history.error && (
|
||||
<Group role="alert">
|
||||
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
|
||||
|
||||
<Button onClick={retryHistory}>{t("bank.retry")}</Button>
|
||||
<Button onClick={vm.history.retry}>{t("bank.retry")}</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{vm.history.data && vm.pageSize !== "all" && (
|
||||
{vm.history.page !== undefined && vm.pageSize !== "all" && (
|
||||
<Group
|
||||
aria-label={t("bank.pagination")}
|
||||
component="nav"
|
||||
@@ -329,7 +308,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||
ref={paginationRef}
|
||||
>
|
||||
<Pagination
|
||||
disabled={vm.history.isFetching}
|
||||
disabled={vm.history.refreshing}
|
||||
getControlProps={getControlProps}
|
||||
getItemProps={getItemProps}
|
||||
onChange={onPageChange}
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
bankPeriod,
|
||||
useBankCard,
|
||||
useBankCards,
|
||||
useBankMutations,
|
||||
useBankOperations,
|
||||
useBankSummary,
|
||||
useUpdateBankCard,
|
||||
} from "@/entities/bank-card";
|
||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
@@ -33,7 +33,7 @@ function useCardPageVm() {
|
||||
: 1;
|
||||
|
||||
const card = useBankCard(cardId);
|
||||
const { updateCard } = useBankMutations();
|
||||
const updateCard = useUpdateBankCard();
|
||||
const [archiveError, setArchiveError] = useState<unknown>();
|
||||
const cards = useBankCards(true);
|
||||
|
||||
@@ -69,7 +69,9 @@ function useCardPageVm() {
|
||||
return {
|
||||
color: isOutgoing ? "red" : "green",
|
||||
editable:
|
||||
Boolean(cards.data)
|
||||
!history.isPlaceholderData
|
||||
&& !card.data?.archivedAt
|
||||
&& Boolean(cards.data)
|
||||
&& !cards.data?.some(
|
||||
c =>
|
||||
(c.id === item.cardId || c.id === item.targetCardId)
|
||||
@@ -94,27 +96,42 @@ function useCardPageVm() {
|
||||
return {
|
||||
archiveError,
|
||||
archivePending: updateCard.isPending,
|
||||
card,
|
||||
cards,
|
||||
canAddOperation: !card.data?.archivedAt && Boolean(cards.data),
|
||||
card: card.data,
|
||||
cardLoading: card.isLoading,
|
||||
cardRefreshError: card.isError,
|
||||
cardRefreshing: card.isFetching,
|
||||
cards: cards.data,
|
||||
cardsFeedback: cards.isError
|
||||
? { cached: Boolean(cards.data), error: cards.error }
|
||||
: undefined,
|
||||
closeDeletion: () => setDeleting(null),
|
||||
closeEditing: () => setEditingCard(false),
|
||||
closeOperation: () => setOperation(null),
|
||||
deleting,
|
||||
editableOperation: operation === "new" ? undefined : (operation ?? undefined),
|
||||
editingCard,
|
||||
history,
|
||||
history: {
|
||||
changingPage: history.isPlaceholderData,
|
||||
error: history.isError,
|
||||
loading: history.isLoading,
|
||||
page: history.data?.page,
|
||||
refreshing: history.isFetching,
|
||||
retry: () => void history.refetch(),
|
||||
},
|
||||
notFound: isApiErrorCode(
|
||||
apiErrorCode(card.error),
|
||||
ApiErrorCode.BankNotFound,
|
||||
),
|
||||
openCardEditor: () => setEditingCard(true),
|
||||
openNewOperation: () => setOperation("new"),
|
||||
operation,
|
||||
page,
|
||||
pageSize,
|
||||
period,
|
||||
retryCard: () => void card.refetch(),
|
||||
retryCards: () => void cards.refetch(),
|
||||
rows,
|
||||
setDeleting,
|
||||
setEditingCard,
|
||||
setOperation,
|
||||
setPage: (value: number) =>
|
||||
setParameters({ ...period, limit: pageSize, page: String(value) }),
|
||||
setPageSize: (value: null | string) => {
|
||||
@@ -125,7 +142,13 @@ function useCardPageVm() {
|
||||
if (value[0] && value[1])
|
||||
setParameters({ from: value[0], limit: pageSize, to: value[1] });
|
||||
},
|
||||
summary,
|
||||
summary: {
|
||||
data: summary.data,
|
||||
error: summary.isError,
|
||||
fetching: summary.isFetching,
|
||||
loading: summary.isLoading,
|
||||
retry: () => void summary.refetch(),
|
||||
},
|
||||
toggleArchive: async () => {
|
||||
if (!card.data) return;
|
||||
setArchiveError(undefined);
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { CardPageVm } from "./CardPage.vm";
|
||||
|
||||
function useCardPagePresentationVm(vm: CardPageVm) {
|
||||
const { t } = useTranslation();
|
||||
const card = vm.card.data;
|
||||
const card = vm.card;
|
||||
const paginationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const pendingPageRef = useRef<null
|
||||
@@ -23,15 +23,13 @@ function useCardPagePresentationVm(vm: CardPageVm) {
|
||||
const pending = pendingPageRef.current;
|
||||
if (!pending) return;
|
||||
|
||||
if (pending.scope !== scope || vm.history.isError) {
|
||||
pendingPageRef.current
|
||||
|
||||
= null;
|
||||
if (pending.scope !== scope || vm.history.error) {
|
||||
pendingPageRef.current = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page)
|
||||
if (vm.history.changingPage || vm.history.page !== pending.page)
|
||||
return;
|
||||
const pagination = paginationRef.current;
|
||||
|
||||
@@ -43,18 +41,10 @@ function useCardPagePresentationVm(vm: CardPageVm) {
|
||||
pendingPageRef.current = null;
|
||||
}, [
|
||||
scope,
|
||||
vm.history.data,
|
||||
vm.history.isPlaceholderData,
|
||||
vm.history.isError,
|
||||
vm.history.page,
|
||||
vm.history.changingPage,
|
||||
vm.history.error,
|
||||
]);
|
||||
const onEditCard = () => vm.setEditingCard(true);
|
||||
const archiveActionLabel = t(card?.archivedAt ? "bank.restore" : "bank.archive");
|
||||
const onToggleArchive = () => void vm.toggleArchive();
|
||||
const retryCard = () => void vm.card.refetch();
|
||||
const retryCards = () => void vm.cards.refetch();
|
||||
const retrySummary = () => void vm.summary.refetch();
|
||||
const onAddOperation = () => vm.setOperation("new");
|
||||
const retryHistory = () => void vm.history.refetch();
|
||||
|
||||
const getControlProps: ComponentProps<
|
||||
typeof Pagination
|
||||
@@ -77,20 +67,10 @@ function useCardPagePresentationVm(vm: CardPageVm) {
|
||||
};
|
||||
|
||||
return {
|
||||
archiveActionLabel,
|
||||
card,
|
||||
getControlProps,
|
||||
getItemProps,
|
||||
onAddOperation,
|
||||
onEditCard,
|
||||
onPageChange,
|
||||
onToggleArchive,
|
||||
paginationRef,
|
||||
retryCard,
|
||||
retryCards,
|
||||
retryHistory,
|
||||
retrySummary,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type BankOperation,
|
||||
useBankFormFocus,
|
||||
useBankMutations,
|
||||
useBankOperation,
|
||||
useDeleteBankOperation,
|
||||
} from "@/entities/bank-card";
|
||||
|
||||
type DeleteOperationProps = { onClose: () => void; operation: BankOperation };
|
||||
@@ -18,7 +18,7 @@ function useDeleteOperationVm(props: DeleteOperationProps) {
|
||||
const [latest, setLatest] = useState<BankOperation>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const query = useBankOperation(record.id);
|
||||
const { deleteOperation: operationDeletion } = useBankMutations();
|
||||
const operationDeletion = useDeleteBankOperation();
|
||||
|
||||
const isPending
|
||||
= !opened
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
useBankCategories,
|
||||
useBankExpenses,
|
||||
useBankFormFocus,
|
||||
useBankMutations,
|
||||
useBankOperation,
|
||||
useCreateBankOperation,
|
||||
useUpdateBankOperation,
|
||||
} from "@/entities/bank-card";
|
||||
import { formatDate, formatMoney } from "@/shared/lib";
|
||||
|
||||
@@ -49,7 +50,8 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
|
||||
const [error, setError] = useState<unknown>();
|
||||
const categories = useBankCategories();
|
||||
const mutation = useBankMutations();
|
||||
const operationCreation = useCreateBankOperation();
|
||||
const updateOperation = useUpdateBankOperation();
|
||||
|
||||
const methods = useForm<OperationValues>({
|
||||
defaultValues: {
|
||||
@@ -116,12 +118,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
||||
};
|
||||
|
||||
if (props.operation)
|
||||
await mutation.updateOperation.mutateAsync({
|
||||
await updateOperation.mutateAsync({
|
||||
body: { ...body, version },
|
||||
id: props.operation.id,
|
||||
});
|
||||
else
|
||||
await mutation.createOperation.mutateAsync({
|
||||
await operationCreation.mutateAsync({
|
||||
id: intent,
|
||||
...body,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import type { StocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
import classes from "./StocksPage.module.css";
|
||||
import { useStocksPageViewVm } from "./StocksPageView.vm";
|
||||
|
||||
type StocksPageViewProps = { vm: StocksPageVm };
|
||||
|
||||
@@ -139,7 +138,8 @@ const StocksPageViewResults: FC<StocksPageViewProps> = (props) => {
|
||||
};
|
||||
|
||||
const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
const { onChange, t, vm } = useStocksPageViewVm(props);
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -164,7 +164,7 @@ const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
<TextInput
|
||||
label={t("stock.searchLabel")}
|
||||
maxLength={80}
|
||||
onChange={onChange}
|
||||
onChange={vm.onQueryChange}
|
||||
placeholder={t("stock.searchPlaceholder")}
|
||||
size="md"
|
||||
value={vm.query}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
@@ -35,6 +37,10 @@ function useStocksPageVm() {
|
||||
|| (!result.data
|
||||
&& !result.isError
|
||||
&& result.meta?.sync.state !== "failed")),
|
||||
onQueryChange: (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const q = event.currentTarget.value;
|
||||
setParameters(q ? { q } : {}, { replace: true });
|
||||
},
|
||||
partial: result.meta?.availability === "partial",
|
||||
query,
|
||||
ready:
|
||||
@@ -45,7 +51,6 @@ function useStocksPageVm() {
|
||||
retry: () => {
|
||||
void sync.retry();
|
||||
},
|
||||
setQuery: (q: string) => setParameters(q ? { q } : {}, { replace: true }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { TextInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { StocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
type StocksPageViewProps = { vm: StocksPageVm };
|
||||
|
||||
function useStocksPageViewVm(props: StocksPageViewProps) {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChange: ComponentProps<typeof TextInput>["onChange"] = event =>
|
||||
vm.setQuery(event.currentTarget.value);
|
||||
|
||||
return { onChange, t, vm };
|
||||
}
|
||||
|
||||
export { useStocksPageViewVm };
|
||||
Reference in New Issue
Block a user