refactor: normalize view models and share breadcrumbs and titles (WealthLedger/frontend#143) #147
+6
-1
@@ -25,7 +25,11 @@ const readability = {
|
|||||||
)
|
)
|
||||||
context.report({ messageId: "schema", node });
|
context.report({ messageId: "schema", node });
|
||||||
},
|
},
|
||||||
FunctionDeclaration: checkParameters,
|
FunctionDeclaration(node) {
|
||||||
|
checkParameters(node);
|
||||||
|
if (node.id?.name?.endsWith?.("ViewModel"))
|
||||||
|
context.report({ messageId: "vmName", node: node.id });
|
||||||
|
},
|
||||||
FunctionExpression: checkParameters,
|
FunctionExpression: checkParameters,
|
||||||
ImportDeclaration(node) {
|
ImportDeclaration(node) {
|
||||||
if (
|
if (
|
||||||
@@ -95,6 +99,7 @@ const readability = {
|
|||||||
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
|
||||||
svg: "Own SVG markup in packages/icons, and import its public component.",
|
svg: "Own SVG markup in packages/icons, and import its public component.",
|
||||||
union: "Put complex union members on separate lines.",
|
union: "Put complex union members on separate lines.",
|
||||||
|
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
|
||||||
},
|
},
|
||||||
schema: [],
|
schema: [],
|
||||||
type: "suggestion",
|
type: "suggestion",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useCurrentRoute } from "@/app/model";
|
import { useCurrentRoute } from "@/app/model";
|
||||||
@@ -8,12 +7,12 @@ import { useBankCard } from "@/entities/bank-card";
|
|||||||
import { useBond } from "@/entities/bond";
|
import { useBond } from "@/entities/bond";
|
||||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
import { useStock } from "@/entities/stock";
|
import { useStock } from "@/entities/stock";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
type DocumentTitleProps = { title: string };
|
type DocumentTitleProps = { title: string };
|
||||||
|
|
||||||
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
|
||||||
const { t } = useTranslation();
|
useAppDocumentTitle(props.title);
|
||||||
useDocumentTitle(`${props.title} — ${t("appName")}`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountDocumentTitleProps = {
|
type AccountDocumentTitleProps = {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
|
||||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
|
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
type BoundaryProps = PropsWithChildren<{
|
type BoundaryProps = PropsWithChildren<{
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
@@ -37,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
|
|||||||
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
||||||
const { authenticated } = props;
|
const { authenticated } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
|
useAppDocumentTitle(t("routeError.title"));
|
||||||
const to = authenticated ? "/accounts" : "/login";
|
const to = authenticated ? "/accounts" : "/login";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ import { useCurrentRoute } from "@/app/model";
|
|||||||
|
|
||||||
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
|
||||||
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
|
||||||
import { BondBreadcrumbs } from "./BondBreadcrumbs";
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
import { CardBreadcrumbs } from "./CardBreadcrumbs";
|
import { EntityBreadcrumbs } from "./EntityBreadcrumbs";
|
||||||
import { StockBreadcrumbs } from "./StockBreadcrumbs";
|
|
||||||
|
|
||||||
const AppBreadcrumbs: FC = () => {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -26,9 +24,16 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
stock,
|
stock,
|
||||||
} = useCurrentRoute();
|
} = useCurrentRoute();
|
||||||
|
|
||||||
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
|
if (bond) {
|
||||||
if (stock)
|
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />;
|
||||||
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
|
}
|
||||||
|
|
||||||
|
if (stock) {
|
||||||
|
return (
|
||||||
|
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (account && (section || !account.params.section))
|
if (account && (section || !account.params.section))
|
||||||
return (
|
return (
|
||||||
<Stack gap={0} mb="md">
|
<Stack gap={0} mb="md">
|
||||||
@@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
|
|
||||||
|
if (card) {
|
||||||
|
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
|
||||||
|
|
||||||
if (accounts)
|
if (accounts)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useBond } from "@/entities/bond";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type BondBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useBond(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("bond.searchTitle"), to: "/bonds" },
|
|
||||||
{ label: data?.name ?? t("bond.instrument") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { BondBreadcrumbs };
|
|
||||||
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { BreadcrumbTrail };
|
export { BreadcrumbTrail, type Crumb };
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useBankCard } from "@/entities/bank-card";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type CardBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useBankCard(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("bank.title"), to: "/cards" },
|
|
||||||
{ label: data?.name ?? t("bank.card") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CardBreadcrumbs };
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useBankCard } from "@/entities/bank-card";
|
||||||
|
import { useBond } from "@/entities/bond";
|
||||||
|
import { useStock } from "@/entities/stock";
|
||||||
|
|
||||||
|
import type { Crumb } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
|
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
||||||
|
|
||||||
|
type EntityBreadcrumbsProps = {
|
||||||
|
identifier: string;
|
||||||
|
kind:
|
||||||
|
| "bond"
|
||||||
|
| "card"
|
||||||
|
| "stock";
|
||||||
|
};
|
||||||
|
|
||||||
|
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
|
||||||
|
const { identifier, kind } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const bond = useBond(kind === "bond" ? identifier : "");
|
||||||
|
const card = useBankCard(kind === "card" ? identifier : "");
|
||||||
|
const stock = useStock(kind === "stock" ? identifier : "");
|
||||||
|
|
||||||
|
let parent: Crumb;
|
||||||
|
let name: string;
|
||||||
|
|
||||||
|
if (kind === "bond") {
|
||||||
|
parent = { label: t("bond.searchTitle"), to: "/bonds" };
|
||||||
|
name = bond.data?.name ?? t("bond.instrument");
|
||||||
|
}
|
||||||
|
else if (kind === "stock") {
|
||||||
|
parent = { label: t("stock.searchTitle"), to: "/stocks" };
|
||||||
|
name = stock.data?.name ?? t("stock.share");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
parent = { label: t("bank.title"), to: "/cards" };
|
||||||
|
name = card.data?.name ?? t("bank.card");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BreadcrumbTrail items={[parent, { label: name }]} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EntityBreadcrumbs };
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { FC } from "react";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useStock } from "@/entities/stock";
|
|
||||||
|
|
||||||
import { BreadcrumbTrail } from "./BreadcrumbTrail";
|
|
||||||
|
|
||||||
type StockBreadcrumbsProps = { identifier: string };
|
|
||||||
|
|
||||||
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
|
|
||||||
const { data } = useStock(props.identifier);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbTrail
|
|
||||||
items={[
|
|
||||||
{ label: t("stock.searchTitle"), to: "/stocks" },
|
|
||||||
{ label: data?.name ?? t("stock.share") },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { StockBreadcrumbs };
|
|
||||||
@@ -4,16 +4,16 @@ import {
|
|||||||
TbankConnectionError,
|
TbankConnectionError,
|
||||||
TbankConnectionLoading,
|
TbankConnectionLoading,
|
||||||
} from "./TbankConnectionSection.view";
|
} from "./TbankConnectionSection.view";
|
||||||
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
|
import { useTbankConnectionSectionVm } from "./TbankConnectionSection.vm";
|
||||||
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
|
import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded";
|
||||||
|
|
||||||
const TbankConnectionSection: FC = () => {
|
const TbankConnectionSection: FC = () => {
|
||||||
const viewModel = useTbankConnectionViewModel();
|
const vm = useTbankConnectionSectionVm();
|
||||||
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
if (vm.state === "loading") return <TbankConnectionLoading />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <TbankConnectionError retry={viewModel.retry} />
|
? <TbankConnectionError retry={vm.retry} />
|
||||||
: <TbankConnectionSectionLoaded {...viewModel} />;
|
: <TbankConnectionSectionLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { TbankConnectionSection };
|
export { TbankConnectionSection };
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ import { useAuthenticatedQuery } from "@/shared/api";
|
|||||||
|
|
||||||
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
|
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
|
||||||
|
|
||||||
function useTbankConnectionViewModel() {
|
function useTbankConnectionSectionVm() {
|
||||||
const status = useAuthenticatedQuery({
|
const status = useAuthenticatedQuery({
|
||||||
...tbankConnectionQueryOptions(),
|
...tbankConnectionQueryOptions(),
|
||||||
});
|
});
|
||||||
@@ -22,4 +22,4 @@ function useTbankConnectionViewModel() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useTbankConnectionViewModel };
|
export { useTbankConnectionSectionVm };
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { resource } from "@/test/resource";
|
|||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { AccountEventsPage } from "./AccountEventsPage";
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
|
||||||
|
|
||||||
const Navigation: FC = () => {
|
const Navigation: FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -100,7 +100,7 @@ describe("Upcoming event date filters", () => {
|
|||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
const { result } = renderHook(() => useAccountEventsPageVm(), {
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
|
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
|
||||||
@@ -146,7 +146,7 @@ describe("Upcoming event date filters", () => {
|
|||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useAccountEventsPageViewModel(),
|
() => useAccountEventsPageVm(),
|
||||||
{
|
{
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -361,7 +361,7 @@ it("retains the previous complete forecast when its source refresh fails", async
|
|||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
|
const { result } = renderHook(() => useAccountEventsPageVm(), {
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={["/accounts/all/events"]}>
|
<MemoryRouter initialEntries={["/accounts/all/events"]}>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
|
import { useAccountEventsPageVm } from "./AccountEventsPage.vm";
|
||||||
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
import { AccountEventsPageError } from "./components/AccountEventsPageError.view";
|
||||||
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view";
|
||||||
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
import { AccountEventsPageLoading } from "./components/AccountEventsPageLoading.view";
|
||||||
|
|
||||||
const AccountEventsPage: FC = () => {
|
const AccountEventsPage: FC = () => {
|
||||||
const viewModel = useAccountEventsPageViewModel();
|
const vm = useAccountEventsPageVm();
|
||||||
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
|
if (vm.state === "loading") return <AccountEventsPageLoading />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <AccountEventsPageError {...viewModel} />
|
? <AccountEventsPageError {...vm} />
|
||||||
: <AccountEventsPageLoaded {...viewModel} />;
|
: <AccountEventsPageLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountEventsPage };
|
export { AccountEventsPage };
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ type AccountEventsPageViewModel
|
|||||||
}
|
}
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
function useAccountEventsPageVm(): AccountEventsPageViewModel {
|
||||||
const { accountId = "" } = useParams();
|
const { accountId = "" } = useParams();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -136,4 +136,4 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
|
export { type AccountEventsPageViewModel, useAccountEventsPageVm };
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { server } from "@/test/server";
|
|||||||
import { toIncomeDisplayModel } from "../../model/display";
|
import { toIncomeDisplayModel } from "../../model/display";
|
||||||
import { AccountIncomePage } from "./AccountIncomePage";
|
import { AccountIncomePage } from "./AccountIncomePage";
|
||||||
import {
|
import {
|
||||||
useAccountIncomePageViewModel,
|
useAccountIncomePageVm,
|
||||||
} from "./AccountIncomePage.vm";
|
} from "./AccountIncomePage.vm";
|
||||||
import { IncomePayments } from "./components/IncomePayments.view";
|
import { IncomePayments } from "./components/IncomePayments.view";
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ describe("AccountIncomePage", () => {
|
|||||||
);
|
);
|
||||||
const queryClient = createTestQueryClient();
|
const queryClient = createTestQueryClient();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
|
const { result } = renderHook(() => useAccountIncomePageVm(), {
|
||||||
wrapper: ({ children }) => (
|
wrapper: ({ children }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm";
|
import { useAccountIncomePageVm } from "./AccountIncomePage.vm";
|
||||||
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
import { AccountIncomePageError } from "./components/AccountIncomePageError.view";
|
||||||
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view";
|
||||||
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
import { AccountIncomePageLoading } from "./components/AccountIncomePageLoading.view";
|
||||||
|
|
||||||
const AccountIncomePage: FC = () => {
|
const AccountIncomePage: FC = () => {
|
||||||
const viewModel = useAccountIncomePageViewModel();
|
const vm = useAccountIncomePageVm();
|
||||||
if (viewModel.state === "loading") return <AccountIncomePageLoading />;
|
if (vm.state === "loading") return <AccountIncomePageLoading />;
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <AccountIncomePageError {...viewModel} />
|
? <AccountIncomePageError {...vm} />
|
||||||
: <AccountIncomePageLoaded {...viewModel} />;
|
: <AccountIncomePageLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountIncomePage };
|
export { AccountIncomePage };
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ type AccountIncomePageViewModel
|
|||||||
}
|
}
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
|
const useAccountIncomePageVm = (): AccountIncomePageViewModel => {
|
||||||
const { accountId = "" } = useParams();
|
const { accountId = "" } = useParams();
|
||||||
const [searchParameters, setSearchParameters] = useSearchParams();
|
const [searchParameters, setSearchParameters] = useSearchParams();
|
||||||
|
|
||||||
@@ -146,5 +146,5 @@ function isValidIncomePeriod(period: IncomePeriod): boolean {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
type AccountIncomePageViewModel,
|
type AccountIncomePageViewModel,
|
||||||
useAccountIncomePageViewModel,
|
useAccountIncomePageVm,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageL
|
|||||||
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
import { AccountOperationsPageLoading } from "./components/AccountOperationsPageLoading.view";
|
||||||
|
|
||||||
const AccountOperationsPage: FC = () => {
|
const AccountOperationsPage: FC = () => {
|
||||||
const viewModel = useAccountOperationsPageVm();
|
const vm = useAccountOperationsPageVm();
|
||||||
|
|
||||||
if (viewModel.state === "loading") {
|
if (vm.state === "loading") {
|
||||||
return <AccountOperationsPageLoading />;
|
return <AccountOperationsPageLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewModel.state === "error"
|
return vm.state === "error"
|
||||||
? <AccountOperationsPageError {...viewModel} />
|
? <AccountOperationsPageError {...vm} />
|
||||||
: <AccountOperationsPageLoaded {...viewModel} />;
|
: <AccountOperationsPageLoaded {...vm} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { AccountOperationsPage };
|
export { AccountOperationsPage };
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
import { useAccountPageVm } from "./AccountPage.vm";
|
||||||
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
|
||||||
import { AccountPageError } from "./components/AccountPageError.view";
|
import { AccountPageError } from "./components/AccountPageError.view";
|
||||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
|
||||||
|
|
||||||
const AccountPage: FC = () => {
|
const AccountPage: FC = () => {
|
||||||
const viewModel = useAccountPageViewModel();
|
const vm = useAccountPageVm();
|
||||||
if (viewModel.state === "loading") return <AccountPageLoading />;
|
if (vm.state === "loading") return <AccountPageLoading />;
|
||||||
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
if (vm.state === "error") return <AccountPageError {...vm} />;
|
||||||
if (viewModel.state === "empty")
|
if (vm.state === "empty")
|
||||||
return (
|
return (
|
||||||
<AccountPageEmpty
|
<AccountPageEmpty
|
||||||
account={viewModel.account}
|
account={vm.account}
|
||||||
accountId={viewModel.accountId}
|
accountId={vm.accountId}
|
||||||
incomeSummary={viewModel.incomeSummary}
|
incomeSummary={vm.incomeSummary}
|
||||||
readRetry={viewModel.readRetry}
|
readRetry={vm.readRetry}
|
||||||
recentEvents={viewModel.recentEvents}
|
recentEvents={vm.recentEvents}
|
||||||
recentOperations={viewModel.recentOperations}
|
recentOperations={vm.recentOperations}
|
||||||
source={viewModel.source}
|
source={vm.source}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
account={viewModel.account}
|
account={vm.account}
|
||||||
accountId={viewModel.accountId}
|
accountId={vm.accountId}
|
||||||
concentration={viewModel.concentration}
|
concentration={vm.concentration}
|
||||||
currency={viewModel.currency}
|
currency={vm.currency}
|
||||||
incomeSummary={viewModel.incomeSummary}
|
incomeSummary={vm.incomeSummary}
|
||||||
onCurrencyChange={viewModel.onCurrencyChange}
|
onCurrencyChange={vm.onCurrencyChange}
|
||||||
onSliceChange={viewModel.onSliceChange}
|
onSliceChange={vm.onSliceChange}
|
||||||
onTabChange={viewModel.onTabChange}
|
onTabChange={vm.onTabChange}
|
||||||
partial={viewModel.partial}
|
partial={vm.partial}
|
||||||
portfolio={viewModel.portfolio}
|
portfolio={vm.portfolio}
|
||||||
positionSections={viewModel.positionSections}
|
positionSections={vm.positionSections}
|
||||||
readRetry={viewModel.readRetry}
|
readRetry={vm.readRetry}
|
||||||
recentEvents={viewModel.recentEvents}
|
recentEvents={vm.recentEvents}
|
||||||
recentOperations={viewModel.recentOperations}
|
recentOperations={vm.recentOperations}
|
||||||
slice={viewModel.slice}
|
slice={vm.slice}
|
||||||
source={viewModel.source}
|
source={vm.source}
|
||||||
tab={viewModel.tab}
|
tab={vm.tab}
|
||||||
unrealizedReturns={viewModel.unrealizedReturns}
|
unrealizedReturns={vm.unrealizedReturns}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { resource } from "@/test/resource";
|
|||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
|
|
||||||
import { portfolio } from "../../model/positions.fixtures";
|
import { portfolio } from "../../model/positions.fixtures";
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
import { useAccountPageVm } from "./AccountPage.vm";
|
||||||
|
|
||||||
function configureTestAuth() {
|
function configureTestAuth() {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
@@ -102,12 +102,12 @@ function mockLoadedAccountPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useAccountPageViewModel", () => {
|
describe("useAccountPageVm", () => {
|
||||||
it("prepares recent operations and events state for presentation views", async () => {
|
it("prepares recent operations and events state for presentation views", async () => {
|
||||||
configureTestAuth();
|
configureTestAuth();
|
||||||
mockLoadedAccountPage();
|
mockLoadedAccountPage();
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ describe("useAccountPageViewModel", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ it("keeps overview sections independent when their source data is incomplete", a
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ it("keeps overview sections loaded without exposing technical retry", async () =
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { result } = renderHook(() => useAccountPageViewModel(), {
|
const { result } = renderHook(() => useAccountPageVm(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ type AccountPageViewModel
|
|||||||
| { code?: string; readRetry: AccountPageReadRetry; state: "error" }
|
| { code?: string; readRetry: AccountPageReadRetry; state: "error" }
|
||||||
| { state: "loading" };
|
| { state: "loading" };
|
||||||
|
|
||||||
function useAccountPageViewModel(): AccountPageViewModel {
|
function useAccountPageVm(): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -199,4 +199,4 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useAccountPageViewModel };
|
export { useAccountPageVm };
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
import { useAccountsPageVm } from "./AccountsPage.vm";
|
||||||
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
|
||||||
import { AccountsPageError } from "./components/AccountsPageError.view";
|
import { AccountsPageError } from "./components/AccountsPageError.view";
|
||||||
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
|
import { AccountsPageLoaded } from "./components/AccountsPageLoaded.view";
|
||||||
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
import { AccountsPageLoading } from "./components/AccountsPageLoading.view";
|
||||||
|
|
||||||
const AccountsPage: FC = () => {
|
const AccountsPage: FC = () => {
|
||||||
const viewModel = useAccountsPageViewModel();
|
const vm = useAccountsPageVm();
|
||||||
if (viewModel.state === "loading") return <AccountsPageLoading />;
|
if (vm.state === "loading") return <AccountsPageLoading />;
|
||||||
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
|
if (vm.state === "error") return <AccountsPageError {...vm} />;
|
||||||
if (viewModel.state === "empty") return <AccountsPageEmpty />;
|
if (vm.state === "empty") return <AccountsPageEmpty />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccountsPageLoaded
|
<AccountsPageLoaded
|
||||||
accounts={viewModel.accounts}
|
accounts={vm.accounts}
|
||||||
meta={viewModel.meta}
|
meta={vm.meta}
|
||||||
readRetry={viewModel.readRetry}
|
readRetry={vm.readRetry}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function isAccountsAccessFailure(code?: string) {
|
|||||||
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
|| isApiErrorCode(code, ApiErrorCode.TbankNotConnected);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAccountsPageViewModel(): AccountsPageViewModel {
|
function useAccountsPageVm(): AccountsPageViewModel {
|
||||||
const accounts = useBrokerageAccounts();
|
const accounts = useBrokerageAccounts();
|
||||||
useScreenSync({ screen: "accounts" }, [accounts]);
|
useScreenSync({ screen: "accounts" }, [accounts]);
|
||||||
|
|
||||||
@@ -73,4 +73,4 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useAccountsPageViewModel };
|
export { useAccountsPageVm };
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AuthPageLayout, LoginForm } from "@/features/auth";
|
import { AuthPageLayout, LoginForm } from "@/features/auth";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
const LoginPage: FC = () => {
|
const LoginPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useDocumentTitle(`${t("auth.login")} — ${t("appName")}`);
|
useAppDocumentTitle(t("auth.login"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageLayout title={t("auth.login")}>
|
<AuthPageLayout title={t("auth.login")}>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { Button, Stack, Title } from "@mantine/core";
|
import { Button, Stack, Title } from "@mantine/core";
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { StateView } from "@wealthledger/ui";
|
import { StateView } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
const NotFoundPage: FC = () => {
|
const NotFoundPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useDocumentTitle(`${t("notFound.title")} — ${t("appName")}`);
|
useAppDocumentTitle(t("notFound.title"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { useDocumentTitle } from "@mantine/hooks";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AuthPageLayout, RegisterForm } from "@/features/auth";
|
import { AuthPageLayout, RegisterForm } from "@/features/auth";
|
||||||
|
import { useAppDocumentTitle } from "@/shared/ui";
|
||||||
|
|
||||||
const RegisterPage: FC = () => {
|
const RegisterPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useDocumentTitle(`${t("auth.register")} — ${t("appName")}`);
|
useAppDocumentTitle(t("auth.register"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageLayout title={t("auth.register")}>
|
<AuthPageLayout title={t("auth.register")}>
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { Button, Stack, Title } from "@mantine/core";
|
import { Button, Stack, Title } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useAuth } from "@/features/auth";
|
||||||
import { TbankConnectionSection } from "@/features/tbank-connection";
|
import { TbankConnectionSection } from "@/features/tbank-connection";
|
||||||
|
|
||||||
import { useSettingsPageVm } from "./SettingsPage.vm";
|
|
||||||
|
|
||||||
const SettingsPage: FC = () => {
|
const SettingsPage: FC = () => {
|
||||||
const { onLogout, t } = useSettingsPageVm();
|
const { t } = useTranslation();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
// eslint-disable-next-line frontend/readability -- the page VM was removed as degenerate (frontend#143); a one-off error-swallowing logout is not a ViewModel.
|
||||||
|
const onLogout = async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// The session is already cleared; the route guard owns navigation.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useAuth } from "@/features/auth";
|
|
||||||
|
|
||||||
function useSettingsPageVm() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { logout } = useAuth();
|
|
||||||
|
|
||||||
const onLogout = async () => {
|
|
||||||
try {
|
|
||||||
await logout();
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// The session is already cleared; the route guard owns navigation.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { onLogout, t };
|
|
||||||
}
|
|
||||||
|
|
||||||
export { useSettingsPageVm };
|
|
||||||
@@ -11,3 +11,4 @@ export {
|
|||||||
type ReadRetryProps,
|
type ReadRetryProps,
|
||||||
} from "./ReadRetry/ReadRetry";
|
} from "./ReadRetry/ReadRetry";
|
||||||
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||||
|
export { useAppDocumentTitle } from "./useAppDocumentTitle";
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { useDocumentTitle } from "@mantine/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets `document.title` to `<title> — <appName>`.
|
||||||
|
* Owner of the "page title + app name suffix" formatting rule.
|
||||||
|
*/
|
||||||
|
function useAppDocumentTitle(title: string) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(`${title} — ${t("appName")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useAppDocumentTitle };
|
||||||
Reference in New Issue
Block a user