Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a89b08ee5 | ||
|
|
2844b22dba | ||
|
|
ff67a768ad | ||
|
|
10baefda79 | ||
|
|
f5f4447d60 | ||
|
|
0bc8bc36b7 |
@@ -17,6 +17,10 @@
|
|||||||
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
CSF сохраняет default meta и прямые story exports; configuration exceptions
|
||||||
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
|
||||||
|
|
||||||
|
Тип значений формы выводится из её schema, когда input/output совпадают.
|
||||||
|
Общие props определяются у одного owner; DTO и presentation model сохраняют
|
||||||
|
собственные типы при смысловом отличии. Type re-export сохраняет public imports.
|
||||||
|
|
||||||
## Читаемость: ESLint и review
|
## Читаемость: ESLint и review
|
||||||
|
|
||||||
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
ESLint проверяет ограничение 80 символов (tab = 2), пустые строки между
|
||||||
@@ -33,6 +37,10 @@ CSS и Markdown также держи в 80 колонках там, где эт
|
|||||||
|
|
||||||
Review проверяет то, чего синтаксис не доказывает:
|
Review проверяет то, чего синтаксис не доказывает:
|
||||||
|
|
||||||
|
Обоснованное config exception сопровождается причиной и явным ожиданием в
|
||||||
|
existing lint tests, включая разрешённый и запрещённый примеры. Это позволяет
|
||||||
|
настраивать профиль без случайного ослабления остальных правил.
|
||||||
|
|
||||||
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
- Пустая строка отделяет смысловые шаги: inputs, вычисления, действие, результат.
|
||||||
- Имена derived values описывают значение, например `projectedAmount`.
|
- Имена derived values описывают значение, например `projectedAmount`.
|
||||||
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
- Тернарник остаётся коротким выбором двух значений. Mapping, несколько веток
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ Presentation VM для form-context/UI-only state может использов
|
|||||||
в каталоге: её граф зависимостей не достигает application data/API.
|
в каталоге: её граф зависимостей не достигает application data/API.
|
||||||
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
`useTranslation` и декларативный вывод коллекции допустимы во view.
|
||||||
|
|
||||||
|
ESLint ограничивает прямые data/auth/router hook imports в `*.view.tsx`;
|
||||||
|
type imports, error helpers, router components и form context разрешены.
|
||||||
|
Транзитивный fetching через local presentation hook проверяется review;
|
||||||
|
Storybook дополнительно проверяет reachable graph. Новое исключение должно
|
||||||
|
сохранять presentation-only responsibility.
|
||||||
|
|
||||||
JSX props получают ссылки на функции, включая `onClick`, render props,
|
JSX props получают ссылки на функции, включая `onClick`, render props,
|
||||||
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
`dateParser` и callbacks внутри объектов props. Для React Hook Form предпочитай
|
||||||
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
`useController` в VM вместо inline `Controller.render`. Значение prop с условием
|
||||||
|
|||||||
@@ -185,6 +185,44 @@ export default defineConfig(
|
|||||||
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
||||||
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.view.tsx"],
|
||||||
|
rules: {
|
||||||
|
"no-restricted-imports": ["error", {
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: "Query lifecycle belongs to the data VM.",
|
||||||
|
name: "@tanstack/react-query",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: "Transport belongs to shared/api and the data VM.",
|
||||||
|
name: "openapi-fetch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
message: "Transport belongs to shared/api and the data VM.",
|
||||||
|
name: "openapi-react-query",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
group: ["react-router", "@/entities/*", "@/features/*"],
|
||||||
|
importNamePattern: "^use",
|
||||||
|
message: "Pass route, auth and entity data from the data VM.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
allowTypeImports: true,
|
||||||
|
group: ["@/shared/api", "@/shared/api/*"],
|
||||||
|
importNamePattern: "^use|^api(?:Query)?Client$",
|
||||||
|
message: "Fetching belongs to the data VM; error helpers are allowed.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
|
||||||
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
|
||||||
|
|||||||
Generated
+21
-21
@@ -4082,9 +4082,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6942,9 +6942,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/basic-ftp": {
|
"node_modules/basic-ftp": {
|
||||||
"version": "5.3.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
|
||||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6969,9 +6969,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9668,9 +9668,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10702,9 +10702,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.7.0",
|
"version": "10.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14291,9 +14291,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/release-it/node_modules/undici": {
|
"node_modules/release-it/node_modules/undici": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.1",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
|
||||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -14673,9 +14673,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/serialize-javascript": {
|
"node_modules/serialize-javascript": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
|
||||||
"integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==",
|
"integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -114,6 +114,12 @@
|
|||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.0"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"release-it": {
|
||||||
|
"basic-ftp": "6.2.1",
|
||||||
|
"undici": "7.29.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"esbuild@0.28.2": true,
|
"esbuild@0.28.2": true,
|
||||||
"fsevents@2.3.3": true
|
"fsevents@2.3.3": true
|
||||||
|
|||||||
@@ -338,6 +338,46 @@ test("JSX spacing fixes preserve text interpolation and are stable", async () =>
|
|||||||
assert.equal(again.errorCount, 0);
|
assert.equal(again.errorCount, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("views accept presentation inputs but reject direct data hooks", async () => {
|
||||||
|
const eslint = new ESLint();
|
||||||
|
const filePath = "src/features/instrument-details/ui/InstrumentEvents.view.tsx";
|
||||||
|
|
||||||
|
for (const code of [
|
||||||
|
"import { useStock } from \"@/entities/stock\";",
|
||||||
|
"import { useAuth } from \"@/features/auth\";",
|
||||||
|
"import { useNavigate } from \"react-router\";",
|
||||||
|
"import { apiClient } from \"@/shared/api\";",
|
||||||
|
"import { useQuery } from \"@tanstack/react-query\";",
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
|
assert.ok(result.messages.some(message =>
|
||||||
|
message.ruleId === "no-restricted-imports"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const code of [
|
||||||
|
"import { Link } from \"react-router\";",
|
||||||
|
"import { apiErrorCode } from \"@/shared/api\";",
|
||||||
|
"import type { StockPosition } from \"@/entities/stock\";",
|
||||||
|
"import type { QueryKey } from \"@tanstack/react-query\";",
|
||||||
|
"import { useTranslation } from \"react-i18next\";",
|
||||||
|
"import { useFormContext } from \"react-hook-form\";",
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
|
assert.ok(result.messages.every(message =>
|
||||||
|
message.ruleId !== "no-restricted-imports"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [vm] = await eslint.lintText(
|
||||||
|
"import { useStock } from \"@/entities/stock\";",
|
||||||
|
{ filePath: "src/pages/stock/ui/StockPage/StockPage.vm.ts" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(vm.messages.every(message =>
|
||||||
|
message.ruleId !== "no-restricted-imports"));
|
||||||
|
});
|
||||||
|
|
||||||
test("compact JSX and contract nulls need no local suppressions", async () => {
|
test("compact JSX and contract nulls need no local suppressions", async () => {
|
||||||
const eslint = new ESLint({ fix: true });
|
const eslint = new ESLint({ fix: true });
|
||||||
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { validationMessage } from "@/shared/i18n";
|
|||||||
import type { ExpenseCategory } from "../api/types";
|
import type { ExpenseCategory } from "../api/types";
|
||||||
const MAX_CATEGORY_NAME_LENGTH = 80;
|
const MAX_CATEGORY_NAME_LENGTH = 80;
|
||||||
|
|
||||||
|
type CategoryValues = z.infer<ReturnType<typeof categorySchema>>;
|
||||||
|
|
||||||
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
||||||
return z.object({
|
return z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -27,4 +29,4 @@ function categorySchema(categories?: ExpenseCategory[], editingId?: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { categorySchema };
|
export { categorySchema, type CategoryValues };
|
||||||
|
|||||||
@@ -12,16 +12,14 @@ import { type FC } from "react";
|
|||||||
import { FormProvider, useController, useFormContext } from "react-hook-form";
|
import { FormProvider, useController, useFormContext } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { CategoryValues } from "../../model/category.schema";
|
||||||
|
|
||||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||||
import { BankFeedback } from "../BankFeedback";
|
import { BankFeedback } from "../BankFeedback";
|
||||||
import { useCategoryManagerVm } from "./CategoryManager.vm";
|
import {
|
||||||
|
type CategoryManagerProps,
|
||||||
type CategoryManagerProps = {
|
useCategoryManagerVm,
|
||||||
onClose: () => void;
|
} from "./CategoryManager.vm";
|
||||||
onCreated?: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CategoryValues = { name: string };
|
|
||||||
|
|
||||||
const CategoryFields: FC = () => {
|
const CategoryFields: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useUpdateExpenseCategory,
|
useUpdateExpenseCategory,
|
||||||
} from "../../api/mutations";
|
} from "../../api/mutations";
|
||||||
import { useBankCategories } from "../../api/queries";
|
import { useBankCategories } from "../../api/queries";
|
||||||
import { categorySchema } from "../../model/category.schema";
|
import { categorySchema, type CategoryValues } from "../../model/category.schema";
|
||||||
import { useBankFormFocus } from "../useBankFormFocus";
|
import { useBankFormFocus } from "../useBankFormFocus";
|
||||||
|
|
||||||
type CategoryManagerProps = {
|
type CategoryManagerProps = {
|
||||||
@@ -20,8 +20,6 @@ type CategoryManagerProps = {
|
|||||||
onCreated?: (id: string) => void;
|
onCreated?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CategoryValues = { name: string };
|
|
||||||
|
|
||||||
function useCategoryManagerVm(props: CategoryManagerProps) {
|
function useCategoryManagerVm(props: CategoryManagerProps) {
|
||||||
useBankFormFocus();
|
useBankFormFocus();
|
||||||
const [opened, setOpened] = useState(true);
|
const [opened, setOpened] = useState(true);
|
||||||
@@ -43,6 +41,13 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
|||||||
const isPending = !opened || methods.formState.isSubmitting;
|
const isPending = !opened || methods.formState.isSubmitting;
|
||||||
const onRequestClose = () => !isPending && setOpened(false);
|
const onRequestClose = () => !isPending && setOpened(false);
|
||||||
|
|
||||||
|
const finishEditing = () => {
|
||||||
|
editSessionRef.current++;
|
||||||
|
setEditing(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
|
methods.reset({ name: "" });
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
const onSubmit: ComponentProps<"form">["onSubmit"] = (event) => {
|
||||||
void methods.handleSubmit(async (values) => {
|
void methods.handleSubmit(async (values) => {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
@@ -61,10 +66,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
|||||||
if (result) props.onCreated?.(result.id);
|
if (result) props.onCreated?.(result.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
editSessionRef.current++;
|
finishEditing();
|
||||||
setEditing(undefined);
|
|
||||||
setLatest(undefined);
|
|
||||||
methods.reset({ name: "" });
|
|
||||||
}
|
}
|
||||||
catch (error_) {
|
catch (error_) {
|
||||||
setError(error_);
|
setError(error_);
|
||||||
@@ -90,10 +92,7 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
editSessionRef.current++;
|
finishEditing();
|
||||||
setEditing(undefined);
|
|
||||||
setLatest(undefined);
|
|
||||||
methods.reset({ name: "" });
|
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,4 +130,4 @@ function useCategoryManagerVm(props: CategoryManagerProps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useCategoryManagerVm };
|
export { type CategoryManagerProps, useCategoryManagerVm };
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { WalletIcon } from "@wealthledger/icons";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatDate, formatMoney } from "@/shared/lib";
|
||||||
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { BrokerageAccountCard } from "../../model/display";
|
import type { BrokerageAccountCard } from "../../model/display";
|
||||||
|
|
||||||
@@ -53,6 +54,39 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BrokerageAccountSourceStatusProps = {
|
||||||
|
source: BrokerageAccountCard["source"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const { source } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isSyncing
|
||||||
|
= source?.sync.state === "running" || source?.sync.state === "queued";
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SourceSyncStatus
|
||||||
|
errorText={errorText}
|
||||||
|
lastSuccessfulAt={source?.lastSuccessfulAt}
|
||||||
|
stale={source?.freshness === "stale"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isSyncing && (
|
||||||
|
<Text role="status" size="xs">
|
||||||
|
{t("accounts.refreshing")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||||
const { accounts } = props;
|
const { accounts } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -90,24 +124,7 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
|||||||
|
|
||||||
<BrokerageAccountSummary summary={summary} />
|
<BrokerageAccountSummary summary={summary} />
|
||||||
|
|
||||||
{!!source?.lastSuccessfulAt && (
|
<BrokerageAccountSourceStatus source={source} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(source.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(source?.sync.state === "running"
|
|
||||||
|| source?.sync.state === "queued") && (
|
|
||||||
<Text role="status" size="xs">
|
|
||||||
{t("accounts.refreshing")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{source?.sync.state === "failed" && <Text size="xs">{t("accounts.refreshError")}</Text>}
|
|
||||||
|
|
||||||
{source?.freshness === "stale" && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{!!positionCounts && (
|
{!!positionCounts && (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { LoadingSkeleton } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
|
||||||
|
|
||||||
@@ -33,22 +33,19 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
|
|||||||
const { events } = props;
|
const { events } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= events.state === "loaded" && events.refreshError
|
||||||
|
? t("accounts.refreshError")
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{events.state === "loaded" && (
|
{events.state === "loaded" && (
|
||||||
<>
|
<SourceSyncStatus
|
||||||
{!!events.lastSuccessfulAt && (
|
errorText={errorText}
|
||||||
<Text c="dimmed" size="xs">
|
lastSuccessfulAt={events.lastSuccessfulAt}
|
||||||
{t("investment.sourceTime", {
|
stale={events.stale}
|
||||||
date: formatSnapshotTime(events.lastSuccessfulAt),
|
/>
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!events.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
|
|
||||||
{!!events.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{events.state === "loading" && (
|
{events.state === "loading" && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { toInstrumentEventRows } from "./model/operation-rows";
|
||||||
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";
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
|
|
||||||
|
import { toInstrumentEventRows } from "./operation-rows";
|
||||||
|
|
||||||
|
const operation: BrokerageOperation = {
|
||||||
|
accruedInterest: null,
|
||||||
|
category: "trade",
|
||||||
|
commission: null,
|
||||||
|
description: "Покупка",
|
||||||
|
figi: null,
|
||||||
|
id: "first",
|
||||||
|
instrumentType: "stock",
|
||||||
|
instrumentUid: "uid",
|
||||||
|
logoName: null,
|
||||||
|
occurredAt: "2026-01-01T00:00:00Z",
|
||||||
|
payment: null,
|
||||||
|
price: null,
|
||||||
|
quantity: null,
|
||||||
|
ticker: null,
|
||||||
|
type: "OPERATION_TYPE_BUY",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("fills only missing identity without changing rows or their order", () => {
|
||||||
|
const items = [operation, {
|
||||||
|
...operation,
|
||||||
|
id: "second",
|
||||||
|
logoName: "",
|
||||||
|
ticker: "own",
|
||||||
|
}];
|
||||||
|
|
||||||
|
const original = structuredClone(items);
|
||||||
|
expect(toInstrumentEventRows(items, {
|
||||||
|
logoName: "fallback.png",
|
||||||
|
ticker: "fallback",
|
||||||
|
})).toEqual([
|
||||||
|
{ ...operation, logoName: "fallback.png", ticker: "fallback" },
|
||||||
|
items[1],
|
||||||
|
]);
|
||||||
|
expect(items).toEqual(original);
|
||||||
|
expect(toInstrumentEventRows(items)).toEqual(items);
|
||||||
|
expect(toInstrumentEventRows()).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
|
|
||||||
|
function toInstrumentEventRows(
|
||||||
|
items: BrokerageOperation[] = [],
|
||||||
|
instrument?: Pick<BrokerageOperation, "logoName" | "ticker">,
|
||||||
|
): BrokerageOperation[] {
|
||||||
|
return items.map(item => ({
|
||||||
|
...item,
|
||||||
|
logoName: item.logoName ?? instrument?.logoName ?? null,
|
||||||
|
ticker: item.ticker ?? instrument?.ticker ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toInstrumentEventRows };
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { LoadingSkeleton } from "@wealthledger/ui";
|
import { LoadingSkeleton } from "@wealthledger/ui";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { SectionState } from "../model/section";
|
import type { SectionState } from "../model/section";
|
||||||
|
|
||||||
@@ -80,18 +80,13 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => {
|
|||||||
{props.controls}
|
{props.controls}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{!!props.state.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
<SourceSyncStatus
|
||||||
|
lastSuccessfulAt={props.state.lastSuccessfulAt}
|
||||||
|
stale={props.state.stale}
|
||||||
|
/>
|
||||||
|
|
||||||
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
|
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
|
||||||
|
|
||||||
{!!props.state.lastSuccessfulAt && (
|
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.state.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!props.state.refreshing && (
|
{!!props.state.refreshing && (
|
||||||
<Text c="dimmed" role="status" size="xs">
|
<Text c="dimmed" role="status" size="xs">
|
||||||
{t("accounts.refreshing")}
|
{t("accounts.refreshing")}
|
||||||
|
|||||||
+2
-8
@@ -3,7 +3,7 @@ import type { FC } from "react";
|
|||||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
type InvestmentRefreshStateProps = {
|
type InvestmentRefreshStateProps = {
|
||||||
labelScope?: "accounts" | "portfolio";
|
labelScope?: "accounts" | "portfolio";
|
||||||
@@ -20,13 +20,7 @@ const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
disabled={refreshing}
|
disabled={refreshing}
|
||||||
|
|||||||
+2
-8
@@ -16,7 +16,7 @@ import {
|
|||||||
BrokerageEventCard,
|
BrokerageEventCard,
|
||||||
brokerageEventKey,
|
brokerageEventKey,
|
||||||
} from "@/entities/brokerage-event";
|
} from "@/entities/brokerage-event";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
|
||||||
|
|
||||||
@@ -55,13 +55,7 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
|
|
||||||
|
|||||||
+2
-8
@@ -13,7 +13,7 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { FormProvider } from "react-hook-form";
|
import { FormProvider } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
|
||||||
|
|
||||||
@@ -47,13 +47,7 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.page} gap="lg">
|
<Stack className={classes.page} gap="lg">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={1}>{t("income.title")}</Title>
|
<Title order={1}>{t("income.title")}</Title>
|
||||||
|
|
||||||
|
|||||||
+2
-9
@@ -7,7 +7,6 @@ import {
|
|||||||
MultiSelect,
|
MultiSelect,
|
||||||
Progress,
|
Progress,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
@@ -15,7 +14,7 @@ import { StateView } from "@wealthledger/ui";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
|
||||||
|
|
||||||
@@ -63,13 +62,7 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
|
||||||
<Text c="dimmed" size="xs">
|
|
||||||
{t("investment.sourceTime", {
|
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Title order={1}>{t("ledger.title")}</Title>
|
<Title order={1}>{t("ledger.title")}</Title>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { Link } from "react-router";
|
|||||||
|
|
||||||
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
|
||||||
|
|
||||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
import { formatMoney } from "@/shared/lib";
|
||||||
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
type IncomeSummaryProps
|
type IncomeSummaryProps
|
||||||
= | {
|
= | {
|
||||||
@@ -25,6 +26,12 @@ type IncomeSummaryProps
|
|||||||
|
|
||||||
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= props.state === "loaded" && props.refreshError
|
||||||
|
? t("income.refreshError")
|
||||||
|
: null;
|
||||||
|
|
||||||
if (props.state === "loading" || props.state === "partial")
|
if (props.state === "loading" || props.state === "partial")
|
||||||
return (
|
return (
|
||||||
<Text c="dimmed" role="status">
|
<Text c="dimmed" role="status">
|
||||||
@@ -59,17 +66,11 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{!!props.lastSuccessfulAt && (
|
<SourceSyncStatus
|
||||||
<Text c="dimmed" size="xs">
|
errorText={errorText}
|
||||||
{t("investment.sourceTime", {
|
lastSuccessfulAt={props.lastSuccessfulAt}
|
||||||
date: formatSnapshotTime(props.lastSuccessfulAt),
|
stale={props.stale}
|
||||||
})}
|
/>
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
|
|
||||||
{!!props.refreshError && <Text role="status">{t("income.refreshError")}</Text>}
|
|
||||||
|
|
||||||
{props.summary.totalsByCurrency.map(total => (
|
{props.summary.totalsByCurrency.map(total => (
|
||||||
<Paper key={total.currency} p="sm" withBorder>
|
<Paper key={total.currency} p="sm" withBorder>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { LedgerEntryRow } from "@/entities/broker-operation";
|
import { LedgerEntryRow } from "@/entities/broker-operation";
|
||||||
import { formatSnapshotTime } from "@/shared/lib";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
|
|
||||||
import type { AccountPageRecentOperations } from "../../../model/previews";
|
import type { AccountPageRecentOperations } from "../../../model/previews";
|
||||||
|
|
||||||
@@ -18,22 +18,19 @@ const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { operations } = props;
|
const { operations } = props;
|
||||||
|
|
||||||
|
const errorText
|
||||||
|
= operations.state === "loaded" && operations.refreshError
|
||||||
|
? t("accounts.refreshError")
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{operations.state === "loaded" && (
|
{operations.state === "loaded" && (
|
||||||
<>
|
<SourceSyncStatus
|
||||||
{!!operations.lastSuccessfulAt && (
|
errorText={errorText}
|
||||||
<Text c="dimmed" size="xs">
|
lastSuccessfulAt={operations.lastSuccessfulAt}
|
||||||
{t("investment.sourceTime", {
|
stale={operations.stale}
|
||||||
date: formatSnapshotTime(operations.lastSuccessfulAt),
|
/>
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!!operations.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
|
||||||
|
|
||||||
{!!operations.refreshError && <Text role="status">{t("accounts.refreshError")}</Text>}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{operations.state === "loading" && (
|
{operations.state === "loading" && (
|
||||||
|
|||||||
@@ -203,6 +203,37 @@ describe("AccountsPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["idle", "failed"] as const)(
|
||||||
|
"keeps partial empty membership distinct from no accounts (sync=%s)",
|
||||||
|
async (state) => {
|
||||||
|
server.use(
|
||||||
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
HttpResponse.json(resource([], {
|
||||||
|
availability: "partial",
|
||||||
|
sync: { errorCode: null, id: null, state },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const client = createTestQueryClient();
|
||||||
|
renderPage(client);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
|
||||||
|
data: [],
|
||||||
|
meta: { availability: "partial" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Нет инвестиционных счетов"))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
if (state === "failed")
|
||||||
|
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
|
||||||
|
expect(await screen.findByRole("button", { name: "Повторить" }))
|
||||||
|
.toBeVisible();
|
||||||
|
else
|
||||||
|
// eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract.
|
||||||
|
expect(screen.getByRole("status")).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("keeps unknown membership loading instead of declaring no accounts", async () => {
|
it("keeps unknown membership loading instead of declaring no accounts", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts", () =>
|
http.get("*/api/v1/brokerage-accounts", () =>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function useAccountsPageViewModel() {
|
|||||||
) {
|
) {
|
||||||
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
|
if (!accounts.isError && accounts.meta?.sync.state !== "failed")
|
||||||
return { state: "loading" as const };
|
return { state: "loading" as const };
|
||||||
if (accounts.isError || accounts.meta?.sync.state === "failed")
|
|
||||||
return {
|
return {
|
||||||
code: apiErrorCode(accounts.error),
|
code: apiErrorCode(accounts.error),
|
||||||
retry: sync.retry,
|
retry: sync.retry,
|
||||||
@@ -23,23 +23,23 @@ function useAccountsPageViewModel() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accounts.data?.length)
|
const refresh = {
|
||||||
return {
|
|
||||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
||||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
||||||
refreshing: accounts.isFetching || sync.refreshing,
|
refreshing: accounts.isFetching || sync.refreshing,
|
||||||
retry: sync.retry,
|
retry: sync.retry,
|
||||||
sourceError: sync.sourceError,
|
sourceError: sync.sourceError,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (accounts.data.length === 0)
|
||||||
|
return {
|
||||||
|
...refresh,
|
||||||
state: "empty" as const,
|
state: "empty" as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
|
||||||
lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
|
...refresh,
|
||||||
refreshError: accounts.isError || sync.transportError || sync.sourceError,
|
|
||||||
refreshing: accounts.isFetching || sync.refreshing,
|
|
||||||
retry: sync.retry,
|
|
||||||
sourceError: sync.sourceError,
|
|
||||||
state: "loaded" as const,
|
state: "loaded" as const,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,14 @@ import {
|
|||||||
useBondQuote,
|
useBondQuote,
|
||||||
} from "@/entities/bond";
|
} from "@/entities/bond";
|
||||||
import { useBrokerageOperationHistory } from "@/entities/broker-operation";
|
import { useBrokerageOperationHistory } from "@/entities/broker-operation";
|
||||||
import { section, useInstrumentParameters } from "@/features/instrument-details";
|
import {
|
||||||
|
section,
|
||||||
|
toInstrumentEventRows,
|
||||||
|
useInstrumentParameters,
|
||||||
|
} from "@/features/instrument-details";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { instrumentLogoUrl } from "@/shared/lib";
|
import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
||||||
|
|
||||||
@@ -62,20 +66,7 @@ function useBondPageVm() {
|
|||||||
code: apiErrorCode(bond.error),
|
code: apiErrorCode(bond.error),
|
||||||
coupons: coupons.data,
|
coupons: coupons.data,
|
||||||
couponsState: section(coupons, sync.retry),
|
couponsState: section(coupons, sync.retry),
|
||||||
eventRows:
|
eventRows: toInstrumentEventRows(operations.data?.items, bond.data),
|
||||||
operations.data?.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
logoName:
|
|
||||||
item.logoName
|
|
||||||
?? bond.data?.logoName
|
|
||||||
|
|
||||||
?? null,
|
|
||||||
ticker:
|
|
||||||
item.ticker
|
|
||||||
?? bond.data?.ticker
|
|
||||||
|
|
||||||
?? null,
|
|
||||||
})) ?? [],
|
|
||||||
filter,
|
filter,
|
||||||
hasPrevious: history.hasPrevious,
|
hasPrevious: history.hasPrevious,
|
||||||
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
||||||
@@ -92,9 +83,7 @@ function useBondPageVm() {
|
|||||||
coupons.data?.coupons
|
coupons.data?.coupons
|
||||||
.filter(event => event.timing !== "upcoming")
|
.filter(event => event.timing !== "upcoming")
|
||||||
.toSorted((a, b) =>
|
.toSorted((a, b) =>
|
||||||
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
|
compareDateDesc(a.paymentDate, b.paymentDate),
|
||||||
a.paymentDate ?? a.recordDate ?? "",
|
|
||||||
),
|
|
||||||
) ?? [],
|
) ?? [],
|
||||||
period,
|
period,
|
||||||
position: position.data,
|
position: position.data,
|
||||||
@@ -110,9 +99,7 @@ function useBondPageVm() {
|
|||||||
coupons.data?.coupons
|
coupons.data?.coupons
|
||||||
.filter(event => event.timing === "upcoming")
|
.filter(event => event.timing === "upcoming")
|
||||||
.toSorted((a, b) =>
|
.toSorted((a, b) =>
|
||||||
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
|
compareDateDesc(b.paymentDate, a.paymentDate),
|
||||||
b.paymentDate ?? b.recordDate ?? "",
|
|
||||||
),
|
|
||||||
) ?? [],
|
) ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { BankCard } from "@/entities/bank-card";
|
||||||
|
|
||||||
|
import type { OperationValues } from "./OperationForm.types";
|
||||||
|
|
||||||
|
import { createOperationSchema } from "./OperationForm.schema";
|
||||||
|
|
||||||
|
const card: BankCard = {
|
||||||
|
archivedAt: null,
|
||||||
|
balance: { currency: "RUB", value: "0" },
|
||||||
|
bankName: null,
|
||||||
|
id: "card",
|
||||||
|
lastFour: null,
|
||||||
|
name: "Карта",
|
||||||
|
openingBalance: { currency: "RUB", value: "0" },
|
||||||
|
openingDate: "2026-01-01",
|
||||||
|
paymentSystem: null,
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const values: OperationValues = {
|
||||||
|
amount: "1",
|
||||||
|
cardId: card.id,
|
||||||
|
categoryId: "",
|
||||||
|
date: "2026-01-02",
|
||||||
|
description: "",
|
||||||
|
kind: "expense",
|
||||||
|
originalExpenseId: "",
|
||||||
|
targetCardId: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("expense category validation", () => {
|
||||||
|
it.each([
|
||||||
|
["", [{ id: "default", isDefault: true }], true],
|
||||||
|
["chosen", [{ id: "chosen", isDefault: false }], true],
|
||||||
|
["", [{ id: "chosen", isDefault: false }], false],
|
||||||
|
["unknown", [{ id: "default", isDefault: true }], false],
|
||||||
|
["", undefined, false],
|
||||||
|
] as const)("checks category %s with current categories %j", (
|
||||||
|
categoryId, categories, valid,
|
||||||
|
) => {
|
||||||
|
const schema = createOperationSchema({
|
||||||
|
cards: [card],
|
||||||
|
categories: categories ? [...categories] : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = schema.safeParse({ ...values, categoryId });
|
||||||
|
expect(result.success).toBe(valid);
|
||||||
|
if (!result.success)
|
||||||
|
// eslint-disable-next-line vitest/no-conditional-expect -- Invalid table cases retain the category field error boundary.
|
||||||
|
expect(result.error.issues.map(issue => issue.path))
|
||||||
|
.toEqual([["categoryId"]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,8 +10,6 @@ import {
|
|||||||
} from "@/entities/bank-card";
|
} from "@/entities/bank-card";
|
||||||
import { validationMessage } from "@/shared/i18n";
|
import { validationMessage } from "@/shared/i18n";
|
||||||
|
|
||||||
import type { OperationValues } from "./OperationForm.types";
|
|
||||||
|
|
||||||
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
|
// ZodIssueCode is deprecated; this is the custom refinement code accepted by Zod.
|
||||||
enum FormIssueCode {
|
enum FormIssueCode {
|
||||||
Custom = "custom",
|
Custom = "custom",
|
||||||
@@ -43,6 +41,8 @@ const operationSchema = z.object({
|
|||||||
targetCardId: z.string(),
|
targetCardId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type OperationValues = z.infer<typeof operationSchema>;
|
||||||
|
|
||||||
function createOperationSchema({
|
function createOperationSchema({
|
||||||
cards,
|
cards,
|
||||||
categories,
|
categories,
|
||||||
@@ -83,16 +83,19 @@ function createOperationSchema({
|
|||||||
path: ["date"],
|
path: ["date"],
|
||||||
...validationMessage("bank.validation.start")(undefined),
|
...validationMessage("bank.validation.start")(undefined),
|
||||||
});
|
});
|
||||||
if (values.kind === "refund")
|
|
||||||
|
if (values.kind === "refund") {
|
||||||
validateRefund(values, context, expense, limit);
|
validateRefund(values, context, expense, limit);
|
||||||
if (
|
|
||||||
values.kind === "expense"
|
return;
|
||||||
&& !categories?.some(
|
}
|
||||||
c =>
|
|
||||||
c.id
|
if (values.kind !== "expense") return;
|
||||||
=== (values.categoryId || categories.find(c => c.isDefault)?.id),
|
|
||||||
)
|
const effectiveCategoryId
|
||||||
)
|
= values.categoryId || categories?.find(c => c.isDefault)?.id;
|
||||||
|
|
||||||
|
if (!categories?.some(c => c.id === effectiveCategoryId))
|
||||||
context.addIssue({
|
context.addIssue({
|
||||||
code: FormIssueCode.Custom,
|
code: FormIssueCode.Custom,
|
||||||
path: ["categoryId"],
|
path: ["categoryId"],
|
||||||
@@ -128,4 +131,4 @@ function validateRefund(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export { createOperationSchema };
|
export { createOperationSchema, type OperationValues };
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
BankOperation,
|
BankOperation,
|
||||||
} from "@/entities/bank-card";
|
} from "@/entities/bank-card";
|
||||||
|
|
||||||
|
import type { OperationValues } from "./OperationForm.schema";
|
||||||
|
|
||||||
type Choice = { label: string; value: string };
|
type Choice = { label: string; value: string };
|
||||||
|
|
||||||
type OperationFieldsProps = {
|
type OperationFieldsProps = {
|
||||||
@@ -26,20 +28,11 @@ type OperationFormProps = {
|
|||||||
operation?: BankOperation;
|
operation?: BankOperation;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OperationValues = {
|
|
||||||
amount: string;
|
|
||||||
cardId: string;
|
|
||||||
categoryId: string;
|
|
||||||
date: string;
|
|
||||||
description: string;
|
|
||||||
kind: BankOperation["kind"];
|
|
||||||
originalExpenseId: string;
|
|
||||||
targetCardId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Choice,
|
Choice,
|
||||||
OperationFieldsProps,
|
OperationFieldsProps,
|
||||||
OperationFormProps,
|
OperationFormProps,
|
||||||
OperationValues,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { type OperationValues } from "./OperationForm.schema";
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
|
const [submittedValues, setSubmittedValues] = useState<OperationValues>();
|
||||||
const [error, setError] = useState<unknown>();
|
const [error, setError] = useState<unknown>();
|
||||||
const categories = useBankCategories();
|
const categories = useBankCategories();
|
||||||
|
const defaultCategoryId = categories.data?.find(c => c.isDefault)?.id;
|
||||||
const operationCreation = useCreateBankOperation();
|
const operationCreation = useCreateBankOperation();
|
||||||
const updateOperation = useUpdateBankOperation();
|
const updateOperation = useUpdateBankOperation();
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
}),
|
}),
|
||||||
...(values.kind === "expense" && {
|
...(values.kind === "expense" && {
|
||||||
categoryId:
|
categoryId:
|
||||||
values.categoryId || categories.data?.find(c => c.isDefault)?.id,
|
values.categoryId || defaultCategoryId,
|
||||||
}),
|
}),
|
||||||
...(values.kind === "refund" && {
|
...(values.kind === "refund" && {
|
||||||
originalExpenseId: values.originalExpenseId,
|
originalExpenseId: values.originalExpenseId,
|
||||||
@@ -169,7 +170,7 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
cards: options,
|
cards: options,
|
||||||
categories:
|
categories:
|
||||||
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
|
categories.data?.map(c => ({ label: c.name, value: c.id })) ?? [],
|
||||||
defaultCategory: categories.data?.find(c => c.isDefault)?.id ?? "",
|
defaultCategory: defaultCategoryId ?? "",
|
||||||
editing: Boolean(props.operation),
|
editing: Boolean(props.operation),
|
||||||
kind,
|
kind,
|
||||||
kinds: bankOperationKinds.map(value => ({
|
kinds: bankOperationKinds.map(value => ({
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import {
|
|||||||
} from "@/entities/stock";
|
} from "@/entities/stock";
|
||||||
import {
|
import {
|
||||||
section,
|
section,
|
||||||
|
toInstrumentEventRows,
|
||||||
useInstrumentParameters,
|
useInstrumentParameters,
|
||||||
} from "@/features/instrument-details";
|
} from "@/features/instrument-details";
|
||||||
import { useScreenSync } from "@/features/tbank-connection";
|
import { useScreenSync } from "@/features/tbank-connection";
|
||||||
import { apiErrorCode } from "@/shared/api";
|
import { apiErrorCode } from "@/shared/api";
|
||||||
import { instrumentLogoUrl } from "@/shared/lib";
|
import { compareDateDesc, instrumentLogoUrl } from "@/shared/lib";
|
||||||
|
|
||||||
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
||||||
|
|
||||||
@@ -65,20 +66,7 @@ function useStockPageVm() {
|
|||||||
companyState: section(company, sync.retry),
|
companyState: section(company, sync.retry),
|
||||||
dividends: dividends.data,
|
dividends: dividends.data,
|
||||||
dividendsState: section(dividends, sync.retry),
|
dividendsState: section(dividends, sync.retry),
|
||||||
eventRows:
|
eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
|
||||||
operations.data?.items.map(item => ({
|
|
||||||
...item,
|
|
||||||
logoName:
|
|
||||||
item.logoName
|
|
||||||
?? stock.data?.logoName
|
|
||||||
|
|
||||||
?? null,
|
|
||||||
ticker:
|
|
||||||
item.ticker
|
|
||||||
?? stock.data?.ticker
|
|
||||||
|
|
||||||
?? null,
|
|
||||||
})) ?? [],
|
|
||||||
filter,
|
filter,
|
||||||
hasPrevious: history.hasPrevious,
|
hasPrevious: history.hasPrevious,
|
||||||
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
logoSrc: instrumentLogoUrl(stock.data?.logoName),
|
||||||
@@ -93,9 +81,7 @@ function useStockPageVm() {
|
|||||||
dividends.data?.dividends
|
dividends.data?.dividends
|
||||||
.filter(event => event.timing !== "upcoming")
|
.filter(event => event.timing !== "upcoming")
|
||||||
.toSorted((a, b) =>
|
.toSorted((a, b) =>
|
||||||
(b.recordDate ?? b.paymentDate ?? "").localeCompare(
|
compareDateDesc(a.paymentDate, b.paymentDate),
|
||||||
a.recordDate ?? a.paymentDate ?? "",
|
|
||||||
),
|
|
||||||
) ?? [],
|
) ?? [],
|
||||||
period,
|
period,
|
||||||
position: position.data,
|
position: position.data,
|
||||||
@@ -113,9 +99,7 @@ function useStockPageVm() {
|
|||||||
dividends.data?.dividends
|
dividends.data?.dividends
|
||||||
.filter(event => event.timing === "upcoming")
|
.filter(event => event.timing === "upcoming")
|
||||||
.toSorted((a, b) =>
|
.toSorted((a, b) =>
|
||||||
(a.recordDate ?? a.paymentDate ?? "").localeCompare(
|
compareDateDesc(b.paymentDate, a.paymentDate),
|
||||||
b.recordDate ?? b.paymentDate ?? "",
|
|
||||||
),
|
|
||||||
) ?? [],
|
) ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { isValidPeriod, moscowDate } from "./calendar";
|
import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
|
||||||
import { calendarDate } from "./calendar.schema";
|
import { calendarDate } from "./calendar.schema";
|
||||||
|
|
||||||
describe("calendar dates", () => {
|
describe("calendar dates", () => {
|
||||||
@@ -32,4 +32,22 @@ describe("calendar dates", () => {
|
|||||||
expect(moscowDate(new Date("2026-12-31T20:59:59Z"))).toBe("2026-12-31");
|
expect(moscowDate(new Date("2026-12-31T20:59:59Z"))).toBe("2026-12-31");
|
||||||
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
|
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compares dates newest first and keeps dateless rows last", () => {
|
||||||
|
const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"];
|
||||||
|
|
||||||
|
expect(dates.toSorted(compareDateDesc)).toEqual([
|
||||||
|
"2026-03-01",
|
||||||
|
"2026-02-01",
|
||||||
|
"2026-01-01",
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(dates.toSorted((a, b) => compareDateDesc(b, a))).toEqual([
|
||||||
|
null,
|
||||||
|
"2026-01-01",
|
||||||
|
"2026-02-01",
|
||||||
|
"2026-03-01",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
|
|||||||
year: "numeric",
|
year: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Newest first. Missing dates sort last in both directions, so a row without a
|
||||||
|
// date never jumps to the top of a "past" list.
|
||||||
|
function compareDateDesc(a: null | string, b: null | string) {
|
||||||
|
return (b ?? "").localeCompare(a ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
function moscowDate(value: Date): string {
|
function moscowDate(value: Date): string {
|
||||||
const parts = moscowFormatter.formatToParts(value);
|
const parts = moscowFormatter.formatToParts(value);
|
||||||
|
|
||||||
@@ -25,4 +31,4 @@ function moscowDate(value: Date): string {
|
|||||||
return `${part("year")}-${part("month")}-${part("day")}`;
|
return `${part("year")}-${part("month")}-${part("day")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { isValidPeriod, moscowDate };
|
export { compareDateDesc, isValidPeriod, moscowDate };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { isValidPeriod, moscowDate } from "./calendar";
|
export { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
|
||||||
export { calendarDate } from "./calendar.schema";
|
export { calendarDate } from "./calendar.schema";
|
||||||
export {
|
export {
|
||||||
formatChartValue,
|
formatChartValue,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { SourceSyncStatus, type SourceSyncStatusProps } from "./SourceSyncStatus";
|
||||||
|
|
||||||
|
function renderStatus(props: SourceSyncStatusProps) {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<SourceSyncStatus {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SourceSyncStatus", () => {
|
||||||
|
it("renders nothing without source data", () => {
|
||||||
|
renderStatus({});
|
||||||
|
|
||||||
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Данные/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the source timestamp", () => {
|
||||||
|
renderStatus({ lastSuccessfulAt: "2026-02-03T10:15:00.000Z" });
|
||||||
|
|
||||||
|
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces the caller-provided error text as a status", () => {
|
||||||
|
renderStatus({ errorText: "Обновление не удалось" });
|
||||||
|
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the notices next to the timestamp they belong to", () => {
|
||||||
|
renderStatus({
|
||||||
|
errorText: "Обновление не удалось",
|
||||||
|
lastSuccessfulAt: "2026-02-03T10:15:00.000Z",
|
||||||
|
stale: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/Данные на/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Данные требуют обновления")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { formatSnapshotTime } from "@/shared/lib";
|
||||||
|
|
||||||
|
// Owns the "when did the source last answer" line and the two notices that
|
||||||
|
// always travel with it. Surfaces keep their own busy indicator: a list card
|
||||||
|
// shows a muted line, a page header a progress bar — that is a product call.
|
||||||
|
type SourceSyncStatusProps = {
|
||||||
|
errorText?: null | string;
|
||||||
|
lastSuccessfulAt?: null | string;
|
||||||
|
stale?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!!props.lastSuccessfulAt && (
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
{t("investment.sourceTime", {
|
||||||
|
date: formatSnapshotTime(props.lastSuccessfulAt),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
|
||||||
|
|
||||||
|
{!!props.errorText && <Text role="status">{props.errorText}</Text>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { SourceSyncStatus, type SourceSyncStatusProps };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";
|
||||||
Reference in New Issue
Block a user