refactor: tighten types and reuse instrument UI
This commit is contained in:
@@ -110,8 +110,8 @@ Render-helper замени самостоятельным presentation component
|
|||||||
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
|
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
|
||||||
|
|
||||||
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
|
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
|
||||||
лежит рядом со своим view, тестом и story; общий `BankForms.module.css`
|
лежит рядом со своим view, тестом и story. Формы используют Mantine
|
||||||
остаётся в `bank-card/ui`.
|
`Fieldset variant="unstyled"` для групповой блокировки полей без локального CSS.
|
||||||
|
|
||||||
## Формы
|
## Формы
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
forbidden: [
|
forbidden: [
|
||||||
|
{
|
||||||
|
from: {
|
||||||
|
path: "^(src|packages/[^/]+/src)/",
|
||||||
|
pathNot: "[.](test|stories|fixtures)[.]|^src/test/",
|
||||||
|
},
|
||||||
|
name: "production-must-not-depend-on-test-code",
|
||||||
|
severity: "error",
|
||||||
|
to: { path: "[.](test|stories|fixtures)[.]|^src/test/" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
from: {},
|
from: {},
|
||||||
name: "no-unresolved",
|
name: "no-unresolved",
|
||||||
|
|||||||
@@ -153,11 +153,13 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"@typescript-eslint/consistent-type-imports": "error",
|
||||||
// React ignores event handler return values; floating promises are still checked in bodies.
|
// React ignores event handler return values; floating promises are still checked in bodies.
|
||||||
"@typescript-eslint/no-misused-promises": [
|
"@typescript-eslint/no-misused-promises": [
|
||||||
"error",
|
"error",
|
||||||
{ checksVoidReturn: { attributes: false } },
|
{ checksVoidReturn: { attributes: false } },
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "error",
|
||||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -197,6 +199,7 @@ export default defineConfig(
|
|||||||
ignoreTypeIndexes: true,
|
ignoreTypeIndexes: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2023",
|
|
||||||
"lib": [
|
"lib": [
|
||||||
"ES2023",
|
"ES2023",
|
||||||
"DOM"
|
"DOM"
|
||||||
],
|
],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
|||||||
@@ -93,3 +93,13 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row[data-wrap] p {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
@media (min-width: 36em) {
|
||||||
|
.row[data-wrap] {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ export default meta;
|
|||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
export const LongContentAndFallback: Story = {};
|
export const LongContentAndFallback: Story = {};
|
||||||
|
|
||||||
|
export const WrappedOperation: Story = { args: { value: "−123 456 789 012 345 678,90 ₽", wrap: true } };
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type EntryCardProps = {
|
|||||||
| "positive";
|
| "positive";
|
||||||
value: string;
|
value: string;
|
||||||
valueLabel: string;
|
valueLabel: string;
|
||||||
|
wrap?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||||
@@ -31,7 +32,12 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className={classes.row} style={surfaceStyle} withBorder>
|
<Paper
|
||||||
|
className={classes.row}
|
||||||
|
data-wrap={props.wrap || undefined}
|
||||||
|
style={surfaceStyle}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023", "DOM"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
|
|||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
|
|
||||||
for (const [filePath, code, rule] of [
|
for (const [filePath, code, rule] of [
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"export function read(value: string) { return value?.trim(); }",
|
||||||
|
"@typescript-eslint/no-unnecessary-condition",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"export function read(value?: string) { return value!.trim(); }",
|
||||||
|
"@typescript-eslint/no-non-null-assertion",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"import { ReactNode } from \"react\"; export type Content = ReactNode;",
|
||||||
|
"@typescript-eslint/consistent-type-imports",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"src/shared/api/problem-details.ts",
|
"src/shared/api/problem-details.ts",
|
||||||
"export function run() { Promise.resolve(); }",
|
"export function run() { Promise.resolve(); }",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ describe("AppRoutes", () => {
|
|||||||
path: { pathname: "/login", state: { returnTo: "//example.com" } },
|
path: { pathname: "/login", state: { returnTo: "//example.com" } },
|
||||||
title: "Вход",
|
title: "Вход",
|
||||||
},
|
},
|
||||||
|
{ endpoint: "login", path: { pathname: "/login", state: { returnTo: 42 } }, title: "Вход" },
|
||||||
|
{ endpoint: "login", path: { pathname: "/login", state: 42 }, title: "Вход" },
|
||||||
])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => {
|
])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => {
|
||||||
let credentials: unknown;
|
let credentials: unknown;
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ class Boundary extends Component<BoundaryProps, State> {
|
|||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
state: State = { hasError: false };
|
override state: State = { hasError: false };
|
||||||
|
|
||||||
componentDidUpdate(previousProps: BoundaryProps) {
|
override componentDidUpdate(previousProps: BoundaryProps) {
|
||||||
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
||||||
this.setState({ hasError: false });
|
this.setState({ hasError: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
override render() {
|
||||||
return this.state.hasError
|
return this.state.hasError
|
||||||
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
? <RouteErrorFallback authenticated={this.props.authenticated} />
|
||||||
: (
|
: (
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ const Public: FC<PropsWithChildren> = (props) => {
|
|||||||
|
|
||||||
if (!ready) return <RouteSkeleton variant="shell" />;
|
if (!ready) return <RouteSkeleton variant="shell" />;
|
||||||
|
|
||||||
const requested = (location.state as null
|
const state: unknown = location.state;
|
||||||
| { returnTo?: string })?.returnTo;
|
|
||||||
|
const requested = state && typeof state === "object" && "returnTo" in state
|
||||||
|
? state.returnTo
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const returnTo
|
const returnTo
|
||||||
= typeof requested === "string"
|
= typeof requested === "string"
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ describe("banking API", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.categories.data?.[0].name).toBe("Food"),
|
expect(result.current.categories.data?.[0]?.name).toBe("Food"),
|
||||||
);
|
);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
result.current.updateCategory.mutateAsync({
|
result.current.updateCategory.mutateAsync({
|
||||||
@@ -412,7 +412,7 @@ describe("banking API", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.categories.data?.[0].name).toBe("Groceries"),
|
expect(result.current.categories.data?.[0]?.name).toBe("Groceries"),
|
||||||
);
|
);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
result.current.updateOperation.mutateAsync({
|
result.current.updateOperation.mutateAsync({
|
||||||
|
|||||||
@@ -46,16 +46,16 @@ function useBankMutationOptions() {
|
|||||||
return {
|
return {
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const errorCode = apiErrorCode(error);
|
const errorCode = apiErrorCode(error);
|
||||||
if (
|
|
||||||
[
|
return [
|
||||||
ApiErrorCode.BankDependentRefunds,
|
ApiErrorCode.BankDependentRefunds,
|
||||||
ApiErrorCode.BankIntentConflict,
|
ApiErrorCode.BankIntentConflict,
|
||||||
ApiErrorCode.BankRefundLimit,
|
ApiErrorCode.BankRefundLimit,
|
||||||
ApiErrorCode.BankVersionConflict,
|
ApiErrorCode.BankVersionConflict,
|
||||||
ApiErrorCode.BankWriteConflict,
|
ApiErrorCode.BankWriteConflict,
|
||||||
].some(code => isApiErrorCode(errorCode, code))
|
].some(code => isApiErrorCode(errorCode, code))
|
||||||
)
|
? invalidate()
|
||||||
return invalidate();
|
: undefined;
|
||||||
},
|
},
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
retry: false,
|
retry: false,
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ function useBankOperations(
|
|||||||
|
|
||||||
return !isAll
|
return !isAll
|
||||||
&& previousQuery?.queryKey.length === options.queryKey.length
|
&& previousQuery?.queryKey.length === options.queryKey.length
|
||||||
&& previous?.path?.id === id
|
&& previous?.path.id === id
|
||||||
&& previous?.query?.month === query.month
|
&& previous?.query?.month === query.month
|
||||||
&& previous?.query?.from === query.from
|
&& previous?.query?.from === query.from
|
||||||
&& previous?.query?.to === query.to
|
&& previous?.query?.to === query.to
|
||||||
@@ -165,6 +165,7 @@ function useBankOperations(
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- An empty HTTP body can violate the generated response contract.
|
||||||
if (!data) throw new Error("Missing bank operations response");
|
if (!data) throw new Error("Missing bank operations response");
|
||||||
if (!isAll) return data;
|
if (!isAll) return data;
|
||||||
items.push(...data.items);
|
items.push(...data.items);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Button, Fieldset, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ import { formatDate } from "@/shared/lib";
|
|||||||
|
|
||||||
import { type BankCard } from "../../api/queries";
|
import { type BankCard } from "../../api/queries";
|
||||||
import { BankFeedback } from "../BankFeedback";
|
import { BankFeedback } from "../BankFeedback";
|
||||||
import classes from "../BankForms.module.css";
|
|
||||||
import { BankCardFields } from "./BankCardForm.view";
|
import { BankCardFields } from "./BankCardForm.view";
|
||||||
import { useBankCardFormVm } from "./BankCardForm.vm";
|
import { useBankCardFormVm } from "./BankCardForm.vm";
|
||||||
|
|
||||||
@@ -40,7 +39,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
<Fieldset disabled={isPending} variant="unstyled">
|
||||||
<Stack>
|
<Stack>
|
||||||
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
<BankCardFields archived={Boolean(props.card?.archivedAt)} />
|
||||||
|
|
||||||
@@ -80,7 +79,7 @@ const BankCardForm: FC<BankCardFormProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</fieldset>
|
</Fieldset>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import type { DateInput } from "@mantine/dates";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
import type { Control } from "react-hook-form";
|
import type { Control } from "react-hook-form";
|
||||||
|
|
||||||
import { DateInput } from "@mantine/dates";
|
|
||||||
import { useController } from "react-hook-form";
|
import { useController } from "react-hook-form";
|
||||||
|
|
||||||
import { bankDateInputValue } from "../../model/form-validation";
|
import { bankDateInputValue } from "../../model/form-validation";
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
.fieldset {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
@@ -78,6 +78,8 @@ const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
|
|||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
@@ -78,15 +78,16 @@ describe("CategoryManager", () => {
|
|||||||
name: "Переименовать",
|
name: "Переименовать",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
assert.isDefined(renameButtons[0]);
|
||||||
fireEvent.click(renameButtons[0]);
|
fireEvent.click(renameButtons[0]);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
await screen.findByRole("button", { name: "Перечитать запись" }),
|
await screen.findByRole("button", { name: "Перечитать запись" }),
|
||||||
);
|
);
|
||||||
await waitFor(() => expect(reads).toBe(3));
|
await waitFor(() => expect(reads).toBe(3));
|
||||||
fireEvent.click(
|
const secondRename = screen.getAllByRole("button", { name: "Переименовать" })[1];
|
||||||
screen.getAllByRole("button", { name: "Переименовать" })[1],
|
assert.isDefined(secondRename);
|
||||||
);
|
fireEvent.click(secondRename);
|
||||||
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
fireEvent.change(screen.getByLabelText(/Название категории/), {
|
||||||
target: { value: "Новая вторая" },
|
target: { value: "Новая вторая" },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import { Badge,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
|
Fieldset,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -14,7 +14,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
import { BankFeedback } from "../BankFeedback";
|
import { BankFeedback } from "../BankFeedback";
|
||||||
import classes from "../BankForms.module.css";
|
|
||||||
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
||||||
|
|
||||||
type CategoryManagerProps = {
|
type CategoryManagerProps = {
|
||||||
@@ -76,7 +75,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<fieldset className={classes.fieldset} disabled={isPending}>
|
<Fieldset disabled={isPending} variant="unstyled">
|
||||||
<Stack>
|
<Stack>
|
||||||
{categoryRows?.map((category) => {
|
{categoryRows?.map((category) => {
|
||||||
return (
|
return (
|
||||||
@@ -149,7 +148,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</fieldset>
|
</Fieldset>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
|
|||||||
<EntryCard
|
<EntryCard
|
||||||
imageSrc={instrumentLogoUrl(event.logoName)}
|
imageSrc={instrumentLogoUrl(event.logoName)}
|
||||||
initial={(
|
initial={(
|
||||||
(event.ticker ?? event.displayName).trim()[0] ?? "?"
|
event.ticker.trim()[0] ?? "?"
|
||||||
).toUpperCase()}
|
).toUpperCase()}
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
|
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
|
||||||
|
|||||||
@@ -345,12 +345,12 @@ describe("AuthProvider", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.cards.data?.[0].id).toBe("a-card"),
|
expect(result.current.cards.data?.[0]?.id).toBe("a-card"),
|
||||||
);
|
);
|
||||||
identity = "b";
|
identity = "b";
|
||||||
await act(() => expireSession());
|
await act(() => expireSession());
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(result.current.cards.data?.[0].id).toBe("b-card"),
|
expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
.fieldset {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
loginSchema,
|
loginSchema,
|
||||||
registerSchema,
|
registerSchema,
|
||||||
} from "../../model/auth.schema";
|
} from "../../model/auth.schema";
|
||||||
import classes from "./AuthForm.module.css";
|
|
||||||
|
|
||||||
type AuthFormProps = {
|
type AuthFormProps = {
|
||||||
onSubmit: (email: string, password: string) => Promise<void>;
|
onSubmit: (email: string, password: string) => Promise<void>;
|
||||||
@@ -25,7 +24,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
const schema = register ? registerSchema : loginSchema;
|
const schema = register ? registerSchema : loginSchema;
|
||||||
|
|
||||||
const methods = useForm<AuthFormValues>({
|
const methods = useForm<AuthFormValues>({
|
||||||
resolver: zodResolver(schema) as Resolver<AuthFormValues>,
|
resolver: zodResolver(schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -34,7 +33,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
|
|
||||||
const [submitError, setSubmitError] = useState<null | string>(null);
|
const [submitError, setSubmitError] = useState<null | string>(null);
|
||||||
|
|
||||||
const onSubmit2 = methods.handleSubmit(async (values) => {
|
const submit = methods.handleSubmit(async (values) => {
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -49,26 +48,26 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form onSubmit={onSubmit2}>
|
<form onSubmit={submit}>
|
||||||
<fieldset className={classes.fieldset} disabled={isSubmitting}>
|
<Fieldset disabled={isSubmitting} variant="unstyled">
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t("auth.email")}
|
label={t("auth.email")}
|
||||||
{...methods.register("email")}
|
{...methods.register("email")}
|
||||||
error={errors.email?.message as string}
|
error={errors.email?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("auth.password")}
|
label={t("auth.password")}
|
||||||
{...methods.register("password")}
|
{...methods.register("password")}
|
||||||
error={errors.password?.message as string}
|
error={errors.password?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{register && (
|
{register && (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("auth.passwordConfirmation")}
|
label={t("auth.passwordConfirmation")}
|
||||||
{...methods.register("passwordConfirmation")}
|
{...methods.register("passwordConfirmation")}
|
||||||
error={errors.passwordConfirmation?.message as string}
|
error={errors.passwordConfirmation?.message}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
|
|||||||
{t(register ? "auth.haveAccount" : "auth.noAccount")}
|
{t(register ? "auth.haveAccount" : "auth.noAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</fieldset>
|
</Fieldset>
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
export { section, type SectionState } from "./model/section";
|
export { section, type SectionState } from "./model/section";
|
||||||
export { useInstrumentParameters } from "./model/useInstrumentParameters";
|
export { useInstrumentParameters } from "./model/useInstrumentParameters";
|
||||||
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
|
export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view";
|
||||||
|
export { default as instrumentDetailsStyles } from "./ui/InstrumentDetails.module.css";
|
||||||
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
|
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
|
||||||
export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view";
|
export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view";
|
||||||
|
export { InstrumentMetric } from "./ui/InstrumentMetric.view";
|
||||||
export { InstrumentPageState } from "./ui/InstrumentPageState.view";
|
export { InstrumentPageState } from "./ui/InstrumentPageState.view";
|
||||||
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
|
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
|
||||||
|
export { InstrumentQuoteStatus } from "./ui/InstrumentQuoteStatus.view";
|
||||||
export { InstrumentSection } from "./ui/InstrumentSection.view";
|
export { InstrumentSection } from "./ui/InstrumentSection.view";
|
||||||
|
|||||||
@@ -11,16 +11,18 @@ function useInstrumentParameters(incomeFilter: "coupons" | "dividends") {
|
|||||||
value => value === parameters.get("period"),
|
value => value === parameters.get("period"),
|
||||||
) ?? "month";
|
) ?? "month";
|
||||||
|
|
||||||
const filters: { categories: OperationCategory[]; value: string }[] = [
|
const defaultFilter: { categories: OperationCategory[]; value: string } = { categories: [], value: "all" };
|
||||||
{ categories: [], value: "all" },
|
|
||||||
|
const filters = [
|
||||||
|
defaultFilter,
|
||||||
{ categories: ["trade"], value: "trades" },
|
{ categories: ["trade"], value: "trades" },
|
||||||
{ categories: ["income"], value: incomeFilter },
|
{ categories: ["income"], value: incomeFilter },
|
||||||
{ categories: ["tax", "fee", "transfer", "other"], value: "other" },
|
{ categories: ["tax", "fee", "transfer", "other"], value: "other" },
|
||||||
];
|
] satisfies { categories: OperationCategory[]; value: string }[];
|
||||||
|
|
||||||
const { categories, value: filter } = filters.find(
|
const { categories, value: filter } = filters.find(
|
||||||
item => item.value === parameters.get("events"),
|
item => item.value === parameters.get("events"),
|
||||||
) ?? filters[0];
|
) ?? defaultFilter;
|
||||||
|
|
||||||
const setParameter = (key: string, value: string) => {
|
const setParameter = (key: string, value: string) => {
|
||||||
const next = new URLSearchParams(parameters);
|
const next = new URLSearchParams(parameters);
|
||||||
|
|||||||
@@ -60,3 +60,32 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.price {
|
||||||
|
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.metricsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
|
||||||
|
gap: var(--mantine-spacing-lg);
|
||||||
|
}
|
||||||
|
.metricsGrid > div {
|
||||||
|
padding: var(--mantine-spacing-sm);
|
||||||
|
background: var(--wl-color-subtle);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
}
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
.metricsGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+3
-3
@@ -2,9 +2,9 @@ import type { FC } from "react";
|
|||||||
|
|
||||||
import { Text } from "@mantine/core";
|
import { Text } from "@mantine/core";
|
||||||
|
|
||||||
type MetricProps = { label: string; value: string };
|
type InstrumentMetricProps = { label: string; value: string };
|
||||||
|
|
||||||
const Metric: FC<MetricProps> = props => (
|
const InstrumentMetric: FC<InstrumentMetricProps> = props => (
|
||||||
<div>
|
<div>
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{props.label}
|
{props.label}
|
||||||
@@ -16,4 +16,4 @@ const Metric: FC<MetricProps> = props => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export { Metric };
|
export { InstrumentMetric };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { Badge, Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
|
type InstrumentQuoteStatusProps = {
|
||||||
|
quote?: { priceAt: null | string; tradingStatus: null | string };
|
||||||
|
};
|
||||||
|
|
||||||
|
const InstrumentQuoteStatus: FC<InstrumentQuoteStatusProps> = (props) => {
|
||||||
|
const { quote } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Badge color="gray" variant="light">
|
||||||
|
{t(`stock.trading.${quote?.tradingStatus}`, {
|
||||||
|
defaultValue: t("stock.trading.unknown"),
|
||||||
|
})}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{!!quote?.priceAt && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("stock.priceAt", { date: formatSnapshotTime(quote.priceAt) })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { InstrumentQuoteStatus };
|
||||||
@@ -103,7 +103,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|||||||
meta ? [meta, ...(meta.dependencies ?? [])] : [],
|
meta ? [meta, ...(meta.dependencies ?? [])] : [],
|
||||||
)
|
)
|
||||||
.flatMap(meta =>
|
.flatMap(meta =>
|
||||||
meta?.sync.id
|
meta.sync.id
|
||||||
&& (meta.sync.state === "running" || meta.sync.state === "queued")
|
&& (meta.sync.state === "running" || meta.sync.state === "queued")
|
||||||
? [meta.sync.id]
|
? [meta.sync.id]
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
|||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type * as BrokerageEvents from "@/entities/brokerage-event";
|
||||||
|
|
||||||
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
import { useBrokerageEvents } from "@/entities/brokerage-event";
|
||||||
|
|
||||||
import { AccountEventsPage } from "./AccountEventsPage";
|
import { AccountEventsPage } from "./AccountEventsPage";
|
||||||
@@ -18,7 +20,7 @@ vi.mock("@/features/tbank-connection", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/entities/brokerage-event", async importOriginal => ({
|
vi.mock("@/entities/brokerage-event", async importOriginal => ({
|
||||||
...(await importOriginal<typeof import("@/entities/brokerage-event")>()),
|
...(await importOriginal<typeof BrokerageEvents>()),
|
||||||
useBrokerageEvents: vi.fn(),
|
useBrokerageEvents: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -156,8 +156,9 @@ function toIncomeDisplayModel(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
|
const dependencies = meta?.dependencies ?? [];
|
||||||
const year = meta?.dependencies?.find(({ role }) => role === "year");
|
const previous = dependencies.find(({ role }) => role === "previous");
|
||||||
|
const year = dependencies.find(({ role }) => role === "year");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const analytics
|
|||||||
},
|
},
|
||||||
totalsByCurrency: [summary("RUB", "10", "2")],
|
totalsByCurrency: [summary("RUB", "10", "2")],
|
||||||
yearComparison: null,
|
yearComparison: null,
|
||||||
} satisfies IncomeAnalytics;
|
} satisfies IncomeAnalytics & { instruments: [unknown] };
|
||||||
|
|
||||||
function comparison(currency: string) {
|
function comparison(currency: string) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
+5
-3
@@ -11,7 +11,7 @@ import {
|
|||||||
useLocation,
|
useLocation,
|
||||||
useNavigate,
|
useNavigate,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { assert, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type BrokerageOperation,
|
type BrokerageOperation,
|
||||||
@@ -264,8 +264,8 @@ describe("AccountOperationsPage", () => {
|
|||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
await screen.findByLabelText("Период");
|
await screen.findByLabelText("Период");
|
||||||
expect(searches[0].get("from")).toBeNull();
|
expect(searches[0]?.get("from")).toBeNull();
|
||||||
expect(searches[0].get("to")).toBeNull();
|
expect(searches[0]?.get("to")).toBeNull();
|
||||||
|
|
||||||
for (const expected of [
|
for (const expected of [
|
||||||
"?categories=trade",
|
"?categories=trade",
|
||||||
@@ -597,6 +597,8 @@ it("recovers an invalid cursor at the first page without clearing applied filter
|
|||||||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
|
||||||
await waitFor(() => expect(searches).toHaveLength(3));
|
await waitFor(() => expect(searches).toHaveLength(3));
|
||||||
|
assert.isDefined(searches[0]);
|
||||||
|
assert.isDefined(searches[2]);
|
||||||
expect(searches[2].get("cursor")).toBeNull();
|
expect(searches[2].get("cursor")).toBeNull();
|
||||||
for (const key of ["from", "to", "categories"])
|
for (const key of ["from", "to", "categories"])
|
||||||
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
expect(searches[2].get(key)).toBe(searches[0].get(key));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
|
|
||||||
const portfolio: Portfolio
|
const portfolio
|
||||||
= {
|
= {
|
||||||
cashBalances: [{ currency: "USD", value: "1" }],
|
cashBalances: [{ currency: "USD", value: "1" }],
|
||||||
isStale: true,
|
isStale: true,
|
||||||
@@ -42,10 +42,10 @@ const portfolio: Portfolio
|
|||||||
ticker: null,
|
ticker: null,
|
||||||
type: "FUND",
|
type: "FUND",
|
||||||
},
|
},
|
||||||
],
|
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
totalsByCurrency: [{ currency: "RUB", value: "150" }],
|
||||||
};
|
} satisfies Portfolio;
|
||||||
|
|
||||||
const position: Portfolio["positions"][number] = {
|
const position: Portfolio["positions"][number] = {
|
||||||
...portfolio.positions[0],
|
...portfolio.positions[0],
|
||||||
|
|||||||
@@ -59,13 +59,13 @@ describe("account position sections", () => {
|
|||||||
"bonds",
|
"bonds",
|
||||||
"other",
|
"other",
|
||||||
]);
|
]);
|
||||||
expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([
|
expect(sections[2]?.groups.map(group => group.positions[0].figi)).toEqual([
|
||||||
"FIGI-FUND",
|
"FIGI-FUND",
|
||||||
...otherTypes.map(type => `FIGI-${type}-NEXT`),
|
...otherTypes.map(type => `FIGI-${type}-NEXT`),
|
||||||
]);
|
]);
|
||||||
expect(sections[0].groups[0].positions[0].shareText).toBe("40 %");
|
expect(sections[0]?.groups[0]?.positions[0].shareText).toBe("40 %");
|
||||||
expect(sections[1].groups[0].positions[0].shareText).toBe("20 %");
|
expect(sections[1]?.groups[0]?.positions[0].shareText).toBe("20 %");
|
||||||
expect(sections[2].groups[1].positions[0].shareText).toBe("20 %");
|
expect(sections[2]?.groups[1]?.positions[0].shareText).toBe("20 %");
|
||||||
expect(snapshot).toEqual(original);
|
expect(snapshot).toEqual(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,10 +125,10 @@ describe("account position sections", () => {
|
|||||||
figi: null,
|
figi: null,
|
||||||
},
|
},
|
||||||
{ ...second, figi: null },
|
{ ...second, figi: null },
|
||||||
])[0].groups,
|
])[0]?.groups,
|
||||||
).toHaveLength(1);
|
).toHaveLength(1);
|
||||||
expect(
|
expect(
|
||||||
sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups,
|
sections([position, { ...second, figi: "OTHER-FIGI" }])[0]?.groups,
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
expect(
|
expect(
|
||||||
|
|
||||||
@@ -143,19 +143,19 @@ describe("account position sections", () => {
|
|||||||
figi: null,
|
figi: null,
|
||||||
isin: null,
|
isin: null,
|
||||||
},
|
},
|
||||||
])[0].groups,
|
])[0]?.groups,
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
|
it("preserves one-account cards and does not merge repeated rows on the same account", () => {
|
||||||
expect(
|
expect(
|
||||||
sections([position, { ...second, account: position.account }])[0].groups,
|
sections([position, { ...second, account: position.account }])[0]?.groups,
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
expect(
|
expect(
|
||||||
sections([
|
sections([
|
||||||
{ ...position, account: undefined },
|
{ ...position, account: undefined },
|
||||||
{ ...second, account: undefined },
|
{ ...second, account: undefined },
|
||||||
])[0].groups,
|
])[0]?.groups,
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -170,9 +170,9 @@ describe("account position sections", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const before = structuredClone(source);
|
const before = structuredClone(source);
|
||||||
const [group] = sections(source)[0].groups;
|
const group = sections(source)[0]?.groups[0];
|
||||||
expect(
|
expect(
|
||||||
group.positions.map(item => [
|
group?.positions.map(item => [
|
||||||
item.quantity,
|
item.quantity,
|
||||||
item.currentValue,
|
item.currentValue,
|
||||||
item.averagePurchasePrice,
|
item.averagePurchasePrice,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type AccountPagePosition = Portfolio["positions"][number] & {
|
|||||||
|
|
||||||
type AccountPagePositionGroup = {
|
type AccountPagePositionGroup = {
|
||||||
key: string;
|
key: string;
|
||||||
positions: AccountPagePosition[];
|
positions: [AccountPagePosition, ...AccountPagePosition[]];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AccountPagePositionSection = {
|
type AccountPagePositionSection = {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function renderPage(ui: React.ReactNode) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const portfolio: Portfolio
|
const portfolio
|
||||||
= {
|
= {
|
||||||
cashBalances: [{ currency: "USD", value: "0.5" }],
|
cashBalances: [{ currency: "USD", value: "0.5" }],
|
||||||
isStale: true,
|
isStale: true,
|
||||||
@@ -97,10 +97,10 @@ const portfolio: Portfolio
|
|||||||
ticker: null,
|
ticker: null,
|
||||||
type: "BOND",
|
type: "BOND",
|
||||||
},
|
},
|
||||||
],
|
] satisfies [Portfolio["positions"][number], ...Portfolio["positions"]],
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
|
totalsByCurrency: [{ currency: "RUB", value: "1234.5" }],
|
||||||
};
|
} satisfies Portfolio;
|
||||||
|
|
||||||
function position(
|
function position(
|
||||||
overrides: Partial<Portfolio["positions"][number]>,
|
overrides: Partial<Portfolio["positions"][number]>,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { toAccountPagePositionSections } from "../../model/positions";
|
import { toAccountPagePositionSections } from "../../model/positions";
|
||||||
import { portfolio, position, second } from "../../model/positions.fixtures";
|
import { portfolio, position, second } from "../../model/positions.fixtures";
|
||||||
@@ -13,6 +13,8 @@ describe("Positions grouped by security", () => {
|
|||||||
...portfolio, positions: [position, second],
|
...portfolio, positions: [position, second],
|
||||||
})[0];
|
})[0];
|
||||||
|
|
||||||
|
assert.isDefined(section);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
@@ -27,6 +29,8 @@ describe("Positions grouped by security", () => {
|
|||||||
).getAllByRole("listitem");
|
).getAllByRole("listitem");
|
||||||
|
|
||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(2);
|
||||||
|
assert.isDefined(rows[0]);
|
||||||
|
assert.isDefined(rows[1]);
|
||||||
expect(
|
expect(
|
||||||
within(rows[0]).getByRole("link", { name: "Основной" }),
|
within(rows[0]).getByRole("link", { name: "Основной" }),
|
||||||
).toHaveAttribute("href", "/accounts/a");
|
).toHaveAttribute("href", "/accounts/a");
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
|
// eslint-disable-next-line testing-library/no-manual-cleanup -- Vitest globals are disabled, so RTL cannot register its automatic afterEach cleanup.
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
import { afterEach, expect, it } from "vitest";
|
import { afterEach, assert, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
|
import type { AccountPagePosition, AccountPagePositionSection } from "../../model/positions";
|
||||||
|
|
||||||
@@ -76,6 +76,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
|
|||||||
expect(current.height, context).toBeGreaterThan(0);
|
expect(current.height, context).toBeGreaterThan(0);
|
||||||
if (index === 0) continue;
|
if (index === 0) continue;
|
||||||
const previous = bounds[index - 1];
|
const previous = bounds[index - 1];
|
||||||
|
assert.isDefined(previous);
|
||||||
|
|
||||||
if (kind === "other" && width === 1280) {
|
if (kind === "other" && width === 1280) {
|
||||||
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
.page {
|
|
||||||
max-width: 1120px;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.price {
|
|
||||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.2;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.companyMetrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
|
|
||||||
gap: var(--mantine-spacing-lg);
|
|
||||||
}
|
|
||||||
.embeddedSection {
|
|
||||||
margin-top: var(--mantine-spacing-lg);
|
|
||||||
padding-top: var(--mantine-spacing-lg);
|
|
||||||
border-top: 1px solid var(--wl-color-border);
|
|
||||||
}
|
|
||||||
.companyMetrics > div {
|
|
||||||
padding: var(--mantine-spacing-sm);
|
|
||||||
background: var(--wl-color-subtle);
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
}
|
|
||||||
@media (max-width: 48em) {
|
|
||||||
.companyMetrics {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,23 +14,23 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
InstrumentChart,
|
InstrumentChart,
|
||||||
|
instrumentDetailsStyles,
|
||||||
InstrumentEvents,
|
InstrumentEvents,
|
||||||
InstrumentHeader,
|
InstrumentHeader,
|
||||||
|
InstrumentMetric,
|
||||||
InstrumentPosition,
|
InstrumentPosition,
|
||||||
|
InstrumentQuoteStatus,
|
||||||
} from "@/features/instrument-details";
|
} from "@/features/instrument-details";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatSnapshotTime,
|
|
||||||
} from "@/shared/lib";
|
} from "@/shared/lib";
|
||||||
|
|
||||||
import type { BondPageVm } from "./BondPage.vm";
|
import type { BondPageVm } from "./BondPage.vm";
|
||||||
|
|
||||||
import classes from "./BondPage.module.css";
|
|
||||||
import { BondCoupons } from "./components/BondCoupons.view";
|
import { BondCoupons } from "./components/BondCoupons.view";
|
||||||
import { BondMetrics } from "./components/BondMetrics.view";
|
import { BondMetrics } from "./components/BondMetrics.view";
|
||||||
import { Metric } from "./components/Metric.view";
|
|
||||||
|
|
||||||
type BondPageViewProps = { vm: BondPageVm };
|
type BondPageViewProps = { vm: BondPageVm };
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
{t("bond.percentOfNominal")}
|
{t("bond.percentOfNominal")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className={classes.price}>
|
<Text className={instrumentDetailsStyles.price}>
|
||||||
{vm.quote?.pricePercent == undefined
|
{vm.quote?.pricePercent == undefined
|
||||||
? t("bond.unknown")
|
? t("bond.unknown")
|
||||||
: `${formatDecimal(vm.quote.pricePercent)} %`}
|
: `${formatDecimal(vm.quote.pricePercent)} %`}
|
||||||
@@ -97,17 +97,17 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Group gap="xl" my="sm">
|
<Group gap="xl" my="sm">
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("bond.cleanPrice")}
|
label={t("bond.cleanPrice")}
|
||||||
value={formatMoney(vm.quote?.cleanPrice)}
|
value={formatMoney(vm.quote?.cleanPrice)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("bond.aci")}
|
label={t("bond.aci")}
|
||||||
value={formatMoney(vm.quote?.aci)}
|
value={formatMoney(vm.quote?.aci)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("bond.dirtyPrice")}
|
label={t("bond.dirtyPrice")}
|
||||||
value={formatMoney(vm.quote?.dirtyPrice)}
|
value={formatMoney(vm.quote?.dirtyPrice)}
|
||||||
/>
|
/>
|
||||||
@@ -118,19 +118,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Group>
|
<Group>
|
||||||
<Badge color="gray" variant="light">
|
<InstrumentQuoteStatus quote={vm.quote} />
|
||||||
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
|
|
||||||
defaultValue: t("stock.trading.unknown"),
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{!!vm.quote?.priceAt && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("stock.priceAt", {
|
|
||||||
date: formatSnapshotTime(vm.quote.priceAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!!vm.quote?.unavailable.length && (
|
{!!vm.quote?.unavailable.length && (
|
||||||
@@ -143,7 +131,7 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.page} gap="lg">
|
<Stack className={instrumentDetailsStyles.page} gap="lg">
|
||||||
<InstrumentHeader
|
<InstrumentHeader
|
||||||
logoSrc={vm.logoSrc}
|
logoSrc={vm.logoSrc}
|
||||||
name={bond.name}
|
name={bond.name}
|
||||||
@@ -180,29 +168,29 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={classes.embeddedSection}>
|
<div className={instrumentDetailsStyles.embeddedSection}>
|
||||||
<Title mb="md" order={2} size="h3">
|
<Title mb="md" order={2} size="h3">
|
||||||
{t("bond.terms")}
|
{t("bond.terms")}
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
<div className={classes.companyMetrics}>
|
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("bond.nominal")}
|
label={t("bond.nominal")}
|
||||||
value={formatMoney(bond.nominal)}
|
value={formatMoney(bond.nominal)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("bond.initialNominal")}
|
label={t("bond.initialNominal")}
|
||||||
value={formatMoney(bond.initialNominal)}
|
value={formatMoney(bond.initialNominal)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Metric label={t("bond.maturity")} value={maturityDate} />
|
<InstrumentMetric label={t("bond.maturity")} value={maturityDate} />
|
||||||
|
|
||||||
<Metric label={t("bond.callDate")} value={callDate} />
|
<InstrumentMetric label={t("bond.callDate")} value={callDate} />
|
||||||
|
|
||||||
<Metric label={t("bond.couponFrequency")} value={couponFrequency} />
|
<InstrumentMetric label={t("bond.couponFrequency")} value={couponFrequency} />
|
||||||
|
|
||||||
<Metric label={t("bond.lot")} value={lotSize} />
|
<InstrumentMetric label={t("bond.lot")} value={lotSize} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</InstrumentHeader>
|
</InstrumentHeader>
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { BondCoupons as Coupons } from "@/entities/bond";
|
import type { BondCoupons as Coupons } from "@/entities/bond";
|
||||||
|
|
||||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
import { Metric } from "./Metric.view";
|
|
||||||
|
|
||||||
type BondCouponsProps = {
|
type BondCouponsProps = {
|
||||||
coupons?: Coupons;
|
coupons?: Coupons;
|
||||||
pastCoupons: Coupons["coupons"];
|
pastCoupons: Coupons["coupons"];
|
||||||
@@ -116,10 +114,10 @@ const Coupon: FC<CouponProps> = (props) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
<InstrumentMetric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||||
|
|
||||||
{!!coupon.estimatedAmount && (
|
{!!coupon.estimatedAmount && (
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("stock.yourEstimate")}
|
label={t("stock.yourEstimate")}
|
||||||
value={formatMoney(coupon.estimatedAmount)}
|
value={formatMoney(coupon.estimatedAmount)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import type { BondMetrics as Metrics } from "@/entities/bond";
|
import type { BondMetrics as Metrics } from "@/entities/bond";
|
||||||
|
|
||||||
import { InstrumentSection, type SectionState } from "@/features/instrument-details";
|
import { instrumentDetailsStyles, InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details";
|
||||||
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatDate, formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
import classes from "../BondPage.module.css";
|
|
||||||
import { Metric } from "./Metric.view";
|
|
||||||
|
|
||||||
type BondMetricsProps = { metrics?: Metrics; state: SectionState };
|
type BondMetricsProps = { metrics?: Metrics; state: SectionState };
|
||||||
|
|
||||||
const BondMetrics: FC<BondMetricsProps> = (props) => {
|
const BondMetrics: FC<BondMetricsProps> = (props) => {
|
||||||
@@ -32,17 +29,17 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const couponDate = metrics?.couponDate
|
const couponDate = metrics?.couponDate
|
||||||
? formatDate(metrics?.couponDate)
|
? formatDate(metrics.couponDate)
|
||||||
: t("bond.unknown");
|
: t("bond.unknown");
|
||||||
|
|
||||||
const effectiveYield = metrics?.effectiveYield
|
const effectiveYield = metrics?.effectiveYield
|
||||||
? `${formatDecimal(metrics?.effectiveYield.value)} %`
|
? `${formatDecimal(metrics.effectiveYield.value)} %`
|
||||||
: t("bond.unknown");
|
: t("bond.unknown");
|
||||||
|
|
||||||
const couponYield
|
const couponYield
|
||||||
= metrics?.couponYieldPercent == undefined
|
= metrics?.couponYieldPercent == undefined
|
||||||
? t("bond.unknown")
|
? t("bond.unknown")
|
||||||
: `${formatDecimal(metrics?.couponYieldPercent)} %`;
|
: `${formatDecimal(metrics.couponYieldPercent)} %`;
|
||||||
|
|
||||||
let yieldContent;
|
let yieldContent;
|
||||||
|
|
||||||
@@ -50,13 +47,13 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
|||||||
yieldContent = (
|
yieldContent = (
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{t("bond.yieldSource", {
|
{t("bond.yieldSource", {
|
||||||
at: formatSnapshotTime(metrics?.effectiveYield.tradeAt),
|
at: formatSnapshotTime(metrics.effectiveYield.tradeAt),
|
||||||
date: formatDate(metrics?.effectiveYield.date),
|
date: formatDate(metrics.effectiveYield.date),
|
||||||
price: formatDecimal(
|
price: formatDecimal(
|
||||||
metrics?.effectiveYield.pricePercent,
|
metrics.effectiveYield.pricePercent,
|
||||||
),
|
),
|
||||||
target: t(
|
target: t(
|
||||||
`bond.yieldTargets.${metrics?.effectiveYield.dateType}`,
|
`bond.yieldTargets.${metrics.effectiveYield.dateType}`,
|
||||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
@@ -75,26 +72,26 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
|||||||
<InstrumentSection state={state} title={t("bond.metrics")}>
|
<InstrumentSection state={state} title={t("bond.metrics")}>
|
||||||
{!!metrics && (
|
{!!metrics && (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div className={classes.companyMetrics}>
|
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||||
<Metric label={t("bond.creditRating")} value={rating} />
|
<InstrumentMetric label={t("bond.creditRating")} value={rating} />
|
||||||
|
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("bond.couponAmount")}
|
label={t("bond.couponAmount")}
|
||||||
value={formatMoney(metrics.coupon)}
|
value={formatMoney(metrics.coupon)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Metric
|
<InstrumentMetric
|
||||||
label={t("bond.couponType")}
|
label={t("bond.couponType")}
|
||||||
value={t(`bond.couponTypes.${metrics.couponType}`, {
|
value={t(`bond.couponTypes.${metrics.couponType}`, {
|
||||||
defaultValue: t("bond.unknown"),
|
defaultValue: t("bond.unknown"),
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Metric label={t("bond.couponDate")} value={couponDate} />
|
<InstrumentMetric label={t("bond.couponDate")} value={couponDate} />
|
||||||
|
|
||||||
<Metric label={t("bond.effectiveYield")} value={effectiveYield} />
|
<InstrumentMetric label={t("bond.effectiveYield")} value={effectiveYield} />
|
||||||
|
|
||||||
<Metric label={t("bond.couponYield")} value={couponYield} />
|
<InstrumentMetric label={t("bond.couponYield")} value={couponYield} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!metrics.creditRating && (
|
{!!metrics.creditRating && (
|
||||||
@@ -128,10 +125,10 @@ const BondMetrics: FC<BondMetricsProps> = (props) => {
|
|||||||
{t("bond.couponYieldNote")}
|
{t("bond.couponYieldNote")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!!metrics?.couponYieldPriceAt && (
|
{!!metrics.couponYieldPriceAt && (
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
{t("bond.couponYieldPriceAt", {
|
{t("bond.couponYieldPriceAt", {
|
||||||
date: formatSnapshotTime(metrics?.couponYieldPriceAt),
|
date: formatSnapshotTime(metrics.couponYieldPriceAt),
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function useBondsPageVm() {
|
|||||||
result.data?.map(bond => ({
|
result.data?.map(bond => ({
|
||||||
...bond,
|
...bond,
|
||||||
logoSrc: instrumentLogoUrl(bond.logoName),
|
logoSrc: instrumentLogoUrl(bond.logoName),
|
||||||
to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`,
|
to: `/bonds/${encodeURIComponent(bond.slug)}`,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
loading:
|
loading:
|
||||||
canSearch
|
canSearch
|
||||||
|
|||||||
@@ -32,19 +32,6 @@
|
|||||||
.operation {
|
.operation {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.operation p {
|
|
||||||
white-space: normal;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.operation > div > div {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
@media (min-width: 36em) {
|
|
||||||
.operation > div:first-child {
|
|
||||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 40%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.operations[data-pending] {
|
.operations[data-pending] {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,12 +102,13 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
initial={row.title[0]}
|
initial={row.title.charAt(0)}
|
||||||
metadata={row.metadata}
|
metadata={row.metadata}
|
||||||
title={row.title}
|
title={row.title}
|
||||||
tone={row.tone}
|
tone={row.tone}
|
||||||
value={row.value}
|
value={row.value}
|
||||||
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
valueLabel={t(`bank.kinds.${row.item.kind}`)}
|
||||||
|
wrap
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -128,6 +129,8 @@ const CardPageViewHistory: FC<CardPageViewProps> = (props) => {
|
|||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CardPageView: FC<CardPageViewProps> = (props) => {
|
const CardPageView: FC<CardPageViewProps> = (props) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { Pagination } from "@mantine/core";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
import { Pagination } from "@mantine/core";
|
|
||||||
import { useLayoutEffect, useRef } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { DateInput } from "@mantine/dates";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps } from "react";
|
||||||
|
|
||||||
import { DateInput } from "@mantine/dates";
|
|
||||||
import { useController, useFormContext } from "react-hook-form";
|
import { useController, useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
.fieldset {
|
|
||||||
border: 0;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
@@ -90,7 +90,7 @@ function createOperationSchema({
|
|||||||
&& !categories?.some(
|
&& !categories?.some(
|
||||||
c =>
|
c =>
|
||||||
c.id
|
c.id
|
||||||
=== (values.categoryId || categories?.find(c => c.isDefault)?.id),
|
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ describe("OperationForm", () => {
|
|||||||
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150");
|
expect(screen.getByLabelText(/Сумма/)).toHaveValue("150");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
|
||||||
await waitFor(() => expect(bodies).toHaveLength(2));
|
await waitFor(() => expect(bodies).toHaveLength(2));
|
||||||
expect(bodies[1].version).toBe(1);
|
expect(bodies[1]?.version).toBe(1);
|
||||||
fireEvent.click(
|
fireEvent.click(
|
||||||
screen.getByRole("button", { name: "Использовать актуальную версию 2" }),
|
screen.getByRole("button", { name: "Использовать актуальную версию 2" }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FC, ReactNode, SubmitEventHandler } from "react";
|
import type { FC, ReactNode, SubmitEventHandler } from "react";
|
||||||
|
|
||||||
import {
|
import { Button,
|
||||||
Button,
|
Fieldset,
|
||||||
Group,
|
Group,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
@@ -20,7 +20,6 @@ import { formatMoney } from "@/shared/lib";
|
|||||||
import type { Choice, OperationFieldsProps } from "./OperationForm.types";
|
import type { Choice, OperationFieldsProps } from "./OperationForm.types";
|
||||||
|
|
||||||
import { useOperationFieldsVm } from "./OperationFields.vm";
|
import { useOperationFieldsVm } from "./OperationFields.vm";
|
||||||
import classes from "./OperationForm.module.css";
|
|
||||||
import { useRefundFieldsVm } from "./RefundFields.vm";
|
import { useRefundFieldsVm } from "./RefundFields.vm";
|
||||||
|
|
||||||
const OperationFields: FC<OperationFieldsProps> = (props) => {
|
const OperationFields: FC<OperationFieldsProps> = (props) => {
|
||||||
@@ -203,7 +202,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
|||||||
title={displayTitle}
|
title={displayTitle}
|
||||||
>
|
>
|
||||||
<form onSubmit={props.onSubmit}>
|
<form onSubmit={props.onSubmit}>
|
||||||
<fieldset className={classes.fieldset} disabled={props.pending}>
|
<Fieldset disabled={props.pending} variant="unstyled">
|
||||||
<Stack>
|
<Stack>
|
||||||
<OperationFields {...props.fields} />
|
<OperationFields {...props.fields} />
|
||||||
|
|
||||||
@@ -283,7 +282,7 @@ const OperationFormView: FC<OperationFormViewProps> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</fieldset>
|
</Fieldset>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
import { describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
@@ -62,9 +62,9 @@ describe("CardsPage", () => {
|
|||||||
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
|
it("preserves exact negative opening amount and the creation intent through unknown failure", async () => {
|
||||||
renderCards();
|
renderCards();
|
||||||
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
await screen.findByRole("heading", { name: "Добавьте первую карту" });
|
||||||
fireEvent.click(
|
const creationButton = screen.getAllByRole("button", { name: "Добавить карту" })[0];
|
||||||
screen.getAllByRole("button", { name: "Добавить карту" })[0],
|
assert.isDefined(creationButton);
|
||||||
);
|
fireEvent.click(creationButton);
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
const submitted: Record<string, unknown>[] = [];
|
const submitted: Record<string, unknown>[] = [];
|
||||||
let resolve: (() => void) | undefined;
|
let resolve: (() => void) | undefined;
|
||||||
@@ -80,7 +80,7 @@ describe("CardsPage", () => {
|
|||||||
return HttpResponse.error();
|
return HttpResponse.error();
|
||||||
}
|
}
|
||||||
|
|
||||||
return HttpResponse.json({ id: submitted[0].id }, { status: 201 });
|
return HttpResponse.json({ id: submitted[0]?.id }, { status: 201 });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fireEvent.change(within(dialog).getByLabelText(/Название карты/), {
|
fireEvent.change(within(dialog).getByLabelText(/Название карты/), {
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ const CardsPageViewCards: FC<CardsPageViewProps> = (props) => {
|
|||||||
</LoadingSkeleton>
|
</LoadingSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CardsPageView: FC<CardsPageViewProps> = (props) => {
|
const CardsPageView: FC<CardsPageViewProps> = (props) => {
|
||||||
|
|||||||
@@ -1,39 +1,11 @@
|
|||||||
.page {
|
|
||||||
max-width: 1120px;
|
|
||||||
margin: 0 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.price {
|
|
||||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.04em;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.2;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
.positive {
|
.positive {
|
||||||
color: var(--mantine-color-green-8);
|
color: var(--wl-color-positive);
|
||||||
}
|
}
|
||||||
.negative {
|
.negative {
|
||||||
color: var(--mantine-color-red-8);
|
color: var(--wl-color-negative);
|
||||||
}
|
}
|
||||||
.dividend {
|
.dividend {
|
||||||
padding: var(--mantine-spacing-md);
|
padding: var(--mantine-spacing-md);
|
||||||
background: var(--mantine-color-green-0);
|
background: var(--mantine-color-green-0);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
}
|
}
|
||||||
.companyMetrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
|
|
||||||
gap: var(--mantine-spacing-lg);
|
|
||||||
}
|
|
||||||
.companyMetrics > div {
|
|
||||||
padding: var(--mantine-spacing-sm);
|
|
||||||
background: var(--wl-color-subtle);
|
|
||||||
border-radius: var(--mantine-radius-md);
|
|
||||||
}
|
|
||||||
@media (max-width: 48em) {
|
|
||||||
.companyMetrics {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { FC } from "react";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
@@ -13,7 +12,10 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
instrumentDetailsStyles,
|
||||||
InstrumentHeader,
|
InstrumentHeader,
|
||||||
|
InstrumentMetric,
|
||||||
|
InstrumentQuoteStatus,
|
||||||
InstrumentChart as StockChart,
|
InstrumentChart as StockChart,
|
||||||
InstrumentEvents as StockEvents,
|
InstrumentEvents as StockEvents,
|
||||||
InstrumentPosition as StockPosition,
|
InstrumentPosition as StockPosition,
|
||||||
@@ -23,7 +25,6 @@ import {
|
|||||||
formatDate,
|
formatDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatSnapshotTime,
|
|
||||||
} from "@/shared/lib";
|
} from "@/shared/lib";
|
||||||
|
|
||||||
import type { StockPageVm } from "./StockPage.vm";
|
import type { StockPageVm } from "./StockPage.vm";
|
||||||
@@ -31,6 +32,16 @@ import type { StockPageVm } from "./StockPage.vm";
|
|||||||
import { StockDividends } from "./components/StockDividends.view";
|
import { StockDividends } from "./components/StockDividends.view";
|
||||||
import classes from "./StockPage.module.css";
|
import classes from "./StockPage.module.css";
|
||||||
|
|
||||||
|
const compactMetricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
notation: "compact",
|
||||||
|
});
|
||||||
|
|
||||||
|
const metricFormatter = new Intl.NumberFormat("ru-RU", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
notation: "standard",
|
||||||
|
});
|
||||||
|
|
||||||
type StockPageViewProps = { vm: StockPageVm };
|
type StockPageViewProps = { vm: StockPageVm };
|
||||||
|
|
||||||
const StockPageView: FC<StockPageViewProps> = (props) => {
|
const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||||
@@ -64,13 +75,13 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
else {
|
else {
|
||||||
quoteContent = (
|
quoteContent = (
|
||||||
<div aria-live="polite">
|
<div aria-live="polite">
|
||||||
<Text className={classes.price} mt="lg">
|
<Text className={instrumentDetailsStyles.price} mt="lg">
|
||||||
{formatMoney(vm.quote?.price)}
|
{formatMoney(vm.quote?.price)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!!vm.quote?.change && (
|
{!!vm.quote?.change && (
|
||||||
<Text className={appearanceClass} fw={600}>
|
<Text className={appearanceClass} fw={600}>
|
||||||
{vm.quote?.change?.value.startsWith("-") ? "" : "+"}
|
{vm.quote.change.value.startsWith("-") ? "" : "+"}
|
||||||
|
|
||||||
{formatMoney(vm.quote.change)}
|
{formatMoney(vm.quote.change)}
|
||||||
|
|
||||||
@@ -95,19 +106,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Group gap="sm" mt="sm">
|
<Group gap="sm" mt="sm">
|
||||||
<Badge color="gray" variant="light">
|
<InstrumentQuoteStatus quote={vm.quote} />
|
||||||
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
|
|
||||||
defaultValue: t("stock.trading.unknown"),
|
|
||||||
})}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{!!vm.quote?.priceAt && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("stock.priceAt", {
|
|
||||||
date: formatSnapshotTime(vm.quote.priceAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!!vm.quote?.unavailable.length && (
|
{!!vm.quote?.unavailable.length && (
|
||||||
@@ -130,25 +129,16 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
if (vm.company) {
|
if (vm.company) {
|
||||||
if (vm.company.metrics.length > 0) {
|
if (vm.company.metrics.length > 0) {
|
||||||
companyMetricsContent = (
|
companyMetricsContent = (
|
||||||
<div className={classes.companyMetrics}>
|
<div className={instrumentDetailsStyles.metricsGrid}>
|
||||||
{vm.company.metrics.map(metric => (
|
{vm.company.metrics.map((metric) => {
|
||||||
<div key={metric.key}>
|
const label = [t(`stock.metrics.${metric.key}`), metric.period].filter(Boolean).join(" · ");
|
||||||
<Text c="dimmed" size="xs">
|
const formatter = metric.unit === "money" ? compactMetricFormatter : metricFormatter;
|
||||||
{t(`stock.metrics.${metric.key}`)}
|
const value = `${formatter.format(metric.value)} ${metricUnit(metric, t("stock.currencyUnknown"))}`;
|
||||||
|
|
||||||
{metric.period ? ` · ${metric.period}` : ""}
|
return (
|
||||||
</Text>
|
<InstrumentMetric key={metric.key} label={label} value={value} />
|
||||||
|
);
|
||||||
<Text fw={600} size="lg">
|
})}
|
||||||
{new Intl.NumberFormat("ru-RU", {
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
notation:
|
|
||||||
metric.unit === "money" ? "compact" : "standard",
|
|
||||||
}).format(metric.value)}{" "}
|
|
||||||
{metricUnit(metric, t("stock.currencyUnknown"))}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,7 +148,7 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.page} gap="lg">
|
<Stack className={instrumentDetailsStyles.page} gap="lg">
|
||||||
<InstrumentHeader
|
<InstrumentHeader
|
||||||
logoSrc={vm.logoSrc}
|
logoSrc={vm.logoSrc}
|
||||||
name={stock.name}
|
name={stock.name}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function useStocksPageVm() {
|
|||||||
result.data?.map(stock => ({
|
result.data?.map(stock => ({
|
||||||
...stock,
|
...stock,
|
||||||
logoSrc: instrumentLogoUrl(stock.logoName),
|
logoSrc: instrumentLogoUrl(stock.logoName),
|
||||||
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`,
|
to: `/stocks/${encodeURIComponent(stock.slug)}`,
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
loading:
|
loading:
|
||||||
canSearch
|
canSearch
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("useAuthenticatedQuery", () => {
|
|||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
expect(requests).not.toHaveBeenCalled();
|
expect(requests).not.toHaveBeenCalled();
|
||||||
expect(result.current.fetchStatus).toBe("idle");
|
expect(result.current?.fetchStatus).toBe("idle");
|
||||||
|
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "token",
|
accessToken: () => "token",
|
||||||
@@ -59,9 +59,9 @@ describe("useAuthenticatedQuery", () => {
|
|||||||
refresh: () => Promise.resolve(true),
|
refresh: () => Promise.resolve(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
await waitFor(() => expect(result.current?.isSuccess).toBe(true));
|
||||||
expect(requests).toHaveBeenCalledOnce();
|
expect(requests).toHaveBeenCalledOnce();
|
||||||
expect(result.current.data?.data).toEqual([
|
expect(result.current?.data?.data).toEqual([
|
||||||
{
|
{
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
name: "Основной",
|
name: "Основной",
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ function isValidPeriod(from: null | string, to: null | string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function moscowDate(value: Date): string {
|
const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
timeZone: "Europe/Moscow",
|
timeZone: "Europe/Moscow",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).formatToParts(value);
|
});
|
||||||
|
|
||||||
|
function moscowDate(value: Date): string {
|
||||||
|
const parts = moscowFormatter.formatToParts(value);
|
||||||
|
|
||||||
const part = (type: string) =>
|
const part = (type: string) =>
|
||||||
parts.find(item => item.type === type)?.value;
|
parts.find(item => item.type === type)?.value;
|
||||||
|
|||||||
+15
-11
@@ -7,17 +7,19 @@ type FormatMoneyOptions = {
|
|||||||
|
|
||||||
type Money = { currency: string; value: string };
|
type Money = { currency: string; value: string };
|
||||||
|
|
||||||
function formatDate(value: string): string {
|
const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
}).format(new Date(value));
|
});
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return dateFormatter.format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDecimal(value: string): string {
|
function formatDecimal(value: string): string {
|
||||||
const [integer, fraction] = value.split(".", DECIMAL_PART_COUNT);
|
const [integer = "", fraction] = value.split(".", DECIMAL_PART_COUNT);
|
||||||
const sign = integer.startsWith("-") ? "-" : "";
|
const sign = integer.startsWith("-") ? "-" : "";
|
||||||
const digits = sign ? integer.slice(1) : integer;
|
const digits = sign ? integer.slice(1) : integer;
|
||||||
const groupSize = 3;
|
const groupSize = 3;
|
||||||
@@ -41,7 +43,7 @@ function formatMoney(
|
|||||||
): string {
|
): string {
|
||||||
if (!money) return "—";
|
if (!money) return "—";
|
||||||
|
|
||||||
const [integerPart, fractionPart = ""] = money.value.split(
|
const [integerPart = "", fractionPart = ""] = money.value.split(
|
||||||
".",
|
".",
|
||||||
DECIMAL_PART_COUNT,
|
DECIMAL_PART_COUNT,
|
||||||
);
|
);
|
||||||
@@ -59,20 +61,22 @@ function formatMoney(
|
|||||||
return `${formatDecimal(decimal)} ${currency}`;
|
return `${formatDecimal(decimal)} ${currency}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSnapshotTime(value: string): string {
|
const snapshotFormatter = new Intl.DateTimeFormat("ru-RU", {
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
|
||||||
dateStyle: "short",
|
dateStyle: "short",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
}).format(new Date(value));
|
});
|
||||||
|
|
||||||
|
function formatSnapshotTime(value: string): string {
|
||||||
|
return snapshotFormatter.format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
const logoSize = 160;
|
const logoSize = 160;
|
||||||
|
|
||||||
|
const chartFormatter = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 });
|
||||||
|
|
||||||
function formatChartValue(value: number) {
|
function formatChartValue(value: number) {
|
||||||
return new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 4 }).format(
|
return chartFormatter.format(value);
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function instrumentLogoUrl(logoName?: null | string): null | string {
|
function instrumentLogoUrl(logoName?: null | string): null | string {
|
||||||
|
|||||||
@@ -61,14 +61,13 @@ const ColorsView: FC = () => {
|
|||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
{Object.keys(designTokens.color.palettes).map(palette => (
|
{Object.entries(designTokens.color.palettes).map(([palette, colors]) => (
|
||||||
<Stack gap="xs" key={palette}>
|
<Stack gap="xs" key={palette}>
|
||||||
<Title order={2}>{palette}</Title>
|
<Title order={2}>{palette}</Title>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 2, lg: 10, sm: 5 }}>
|
<SimpleGrid cols={{ base: 2, lg: 10, sm: 5 }}>
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key.
|
colors.map((value, shade) => (
|
||||||
theme.colors[palette].map((value, shade) => (
|
|
||||||
<Paper key={value} p="xs" withBorder>
|
<Paper key={value} p="xs" withBorder>
|
||||||
<Box aria-hidden="true" bg={value} h={40} />
|
<Box aria-hidden="true" bg={value} h={40} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import tokens from "@wealthledger/tokens/tokens.json";
|
import tokens from "@wealthledger/tokens/tokens.json";
|
||||||
import { describe, expect, it } from "vitest";
|
import { assert, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
function luminance(color: string) {
|
function luminance(color: string) {
|
||||||
const channels = [1, 3, 5]
|
let result = 0;
|
||||||
.map(index => Number.parseInt(color.slice(index, index + 2), 16) / 255)
|
|
||||||
.map(value =>
|
|
||||||
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4,
|
|
||||||
);
|
|
||||||
|
|
||||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
for (const [index, weight] of [0.2126, 0.7152, 0.0722].entries()) {
|
||||||
|
const channel = Number.parseInt(
|
||||||
|
color.slice(1 + index * 2, 3 + index * 2), 16,
|
||||||
|
) / 255;
|
||||||
|
|
||||||
|
const linear = channel <= 0.04045
|
||||||
|
? channel / 12.92
|
||||||
|
: ((channel + 0.055) / 1.055) ** 2.4;
|
||||||
|
|
||||||
|
result += linear * weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("semantic text contrast", () => {
|
describe("semantic text contrast", () => {
|
||||||
@@ -24,6 +32,8 @@ describe("semantic text contrast", () => {
|
|||||||
const foreground
|
const foreground
|
||||||
= palettes[role.color as keyof typeof palettes][role.shade];
|
= palettes[role.color as keyof typeof palettes][role.shade];
|
||||||
|
|
||||||
|
assert.isDefined(foreground);
|
||||||
|
|
||||||
const surfaces = [
|
const surfaces = [
|
||||||
"#ffffff",
|
"#ffffff",
|
||||||
palettes.gray[0],
|
palettes.gray[0],
|
||||||
@@ -34,25 +44,31 @@ describe("semantic text contrast", () => {
|
|||||||
...Object.entries(palettes)
|
...Object.entries(palettes)
|
||||||
.filter(([name]) => name !== "dark")
|
.filter(([name]) => name !== "dark")
|
||||||
.map(
|
.map(
|
||||||
([, palette]) =>
|
([, palette]) => {
|
||||||
`#${[1, 3, 5]
|
const base = palette[0];
|
||||||
|
assert.isDefined(base);
|
||||||
|
|
||||||
|
return `#${[1, 3, 5]
|
||||||
.map(index =>
|
.map(index =>
|
||||||
Math.round(
|
Math.round(
|
||||||
Number.parseInt(palette[0].slice(index, index + 2), 16)
|
Number.parseInt(base.slice(index, index + 2), 16)
|
||||||
* 0.45
|
* 0.45
|
||||||
+ 255 * 0.55,
|
+ 255 * 0.55,
|
||||||
)
|
)
|
||||||
.toString(16)
|
.toString(16)
|
||||||
.padStart(2, "0"),
|
.padStart(2, "0"),
|
||||||
)
|
)
|
||||||
.join("")}`,
|
.join("")}`;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const background of surfaces)
|
for (const background of surfaces) {
|
||||||
|
assert.isDefined(background);
|
||||||
expect(
|
expect(
|
||||||
(luminance(background) + 0.05) / (luminance(foreground) + 0.05),
|
(luminance(background) + 0.05) / (luminance(foreground) + 0.05),
|
||||||
).toBeGreaterThanOrEqual(4.5);
|
).toBeGreaterThanOrEqual(4.5);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-8
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "es2023",
|
|
||||||
"lib": ["ES2023", "ES2024.Promise", "DOM"],
|
"lib": ["ES2023", "ES2024.Promise", "DOM"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
@@ -10,18 +10,12 @@
|
|||||||
},
|
},
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"allowArbitraryExtensions": true,
|
"allowArbitraryExtensions": true,
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx"
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
},
|
||||||
"include": ["src", ".storybook/preview.tsx", "packages/*/src"]
|
"include": ["src", ".storybook/preview.tsx", "packages/*/src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-8
@@ -1,24 +1,18 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "es2023",
|
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"module": "nodenext",
|
"module": "nodenext",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"erasableSyntaxOnly": true
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user