chore: add typed ESLint checks #18
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 17m37s
Conventional commits / commits (pull_request) Successful in 26s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 22m42s
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 17m37s
Conventional commits / commits (pull_request) Successful in 26s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 22m42s
Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
This commit is contained in:
@@ -34,4 +34,4 @@ Testing и без production diff. Перед завершением повто
|
|||||||
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
|
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
|
||||||
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
|
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
|
||||||
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
|
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
|
||||||
| Versions/scripts/check scope | Manifest/lockfile, Biome/TypeScript/dependency configs |
|
| Versions/scripts/check scope | Manifest/lockfile, ESLint/Biome/TypeScript/dependency configs |
|
||||||
|
|||||||
@@ -81,8 +81,34 @@ npm run verify
|
|||||||
```
|
```
|
||||||
|
|
||||||
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
|
||||||
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
|
затем запускает strict TypeScript, Biome, ESLint и проверку его конфигурации,
|
||||||
|
один coverage-backed запуск Vitest,
|
||||||
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
|
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
|
||||||
|
|
||||||
|
`npm run lint` объединяет `lint:biome` и `lint:eslint`. Biome сохраняет
|
||||||
|
форматирование, сортировку импортов, magic numbers и accessibility-проверки.
|
||||||
|
[ESLint](eslint.config.mjs) добавляет typed linting, React Compiler/hooks,
|
||||||
|
Fast Refresh, TanStack Query, выбранные Unicorn-правила и проверки тестов/stories.
|
||||||
|
Prettier не добавлен. `npm run test:lint` проверяет правила на ошибочных примерах
|
||||||
|
и исключение generated/build artifacts. `lint:eslint` сначала собирает private
|
||||||
|
packages, чтобы анализ типов использовал их актуальные declarations.
|
||||||
|
|
||||||
|
Пересекающиеся hook-диагностики принадлежат официальному `react-hooks`.
|
||||||
|
Testing Library проверяет async-корректность; DOM structure/geometry tests
|
||||||
|
сохраняют прямой доступ к узлам. Async doubles допускаются без искусственного
|
||||||
|
`await`; Vitest допускает второй аргумент сообщения и ветвления фиксированных
|
||||||
|
`it.each` сценариев. React event handlers допускают Promise, но их тела остаются
|
||||||
|
под `no-floating-promises`. Локальные исключения объясняют сохранение wire
|
||||||
|
ProblemDetails, generated query keys и раннее подключение rejection assertions
|
||||||
|
в race-тестах. Плагин Query не доказывает корректность всех generated/custom
|
||||||
|
обёрток: transport/cache сценарии по-прежнему проверяют MSW-тесты.
|
||||||
|
|
||||||
|
Дополнительные `no-unnecessary-condition` и `no-deprecated` пока не включены:
|
||||||
|
первому нужна отдельная оценка индексного доступа и защитных runtime guards,
|
||||||
|
второму — отдельная миграция API зависимостей. Пакеты второй волны и
|
||||||
|
`jsx-a11y` без заявленной поддержки ESLint 10 отложены в
|
||||||
|
[workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18).
|
||||||
|
|
||||||
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
|
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
|
||||||
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
|
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
|
||||||
и его заглушки layout API. Только браузерная проверка:
|
и его заглушки layout API. Только браузерная проверка:
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"includes": [
|
"includes": [
|
||||||
|
"eslint.config.mjs",
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
".storybook/main.ts",
|
".storybook/main.ts",
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import react from "@eslint-react/eslint-plugin";
|
||||||
|
import query from "@tanstack/eslint-plugin-query";
|
||||||
|
import vitest from "@vitest/eslint-plugin";
|
||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import { importX } from "eslint-plugin-import-x";
|
||||||
|
import hooks from "eslint-plugin-react-hooks";
|
||||||
|
import refresh from "eslint-plugin-react-refresh";
|
||||||
|
import storybook from "eslint-plugin-storybook";
|
||||||
|
import testingLibrary from "eslint-plugin-testing-library";
|
||||||
|
import unicorn from "eslint-plugin-unicorn";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
globalIgnores([
|
||||||
|
"**/dist/**",
|
||||||
|
"dist-ssr/**",
|
||||||
|
"storybook-static/**",
|
||||||
|
"coverage/**",
|
||||||
|
".vitest/**",
|
||||||
|
".scannerwork/**",
|
||||||
|
"src/shared/api/generated/**",
|
||||||
|
"src/shared/api/spec/**",
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
|
||||||
|
extends: [js.configs.recommended],
|
||||||
|
linterOptions: { reportUnusedDisableDirectives: "error" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"],
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
extends: [tseslint.configs.recommendedTypeChecked],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
|
// React ignores handler return values; their bodies still check floating promises.
|
||||||
|
"@typescript-eslint/no-misused-promises": [
|
||||||
|
"error",
|
||||||
|
{ checksVoidReturn: { attributes: false } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
"packages/*/src/**/*.{ts,tsx}",
|
||||||
|
".storybook/preview.tsx",
|
||||||
|
],
|
||||||
|
extends: [
|
||||||
|
react.configs["recommended-type-checked"],
|
||||||
|
react.configs["disable-naming-convention"],
|
||||||
|
hooks.configs.flat.recommended,
|
||||||
|
query.configs["flat/recommended"],
|
||||||
|
],
|
||||||
|
languageOptions: { globals: globals.browser },
|
||||||
|
plugins: { "import-x": importX, unicorn },
|
||||||
|
rules: {
|
||||||
|
// The official hooks plugin owns Compiler and hook diagnostics.
|
||||||
|
"@eslint-react/error-boundaries": "off",
|
||||||
|
"@eslint-react/exhaustive-deps": "off",
|
||||||
|
"@eslint-react/purity": "off",
|
||||||
|
"@eslint-react/rules-of-hooks": "off",
|
||||||
|
"@eslint-react/set-state-in-effect": "off",
|
||||||
|
"@eslint-react/set-state-in-render": "off",
|
||||||
|
"@eslint-react/static-components": "off",
|
||||||
|
"@eslint-react/unsupported-syntax": "off",
|
||||||
|
"@eslint-react/use-memo": "off",
|
||||||
|
// useContext remains supported; a lint rollout does not require an API migration.
|
||||||
|
"@eslint-react/no-use-context": "off",
|
||||||
|
"import-x/no-default-export": "error",
|
||||||
|
"import-x/exports-last": "error",
|
||||||
|
// Biome owns formatting, import sorting and magic numbers during coexistence.
|
||||||
|
"unicorn/no-array-fill-with-reference-type": "error",
|
||||||
|
"unicorn/no-await-in-promise-methods": "error",
|
||||||
|
"unicorn/no-single-promise-in-promise-methods": "error",
|
||||||
|
"unicorn/prefer-array-some": "error",
|
||||||
|
"unicorn/prefer-includes": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
|
||||||
|
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
|
||||||
|
extends: [refresh.configs.vite],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.test.{ts,tsx}"],
|
||||||
|
extends: [vitest.configs.recommended],
|
||||||
|
rules: {
|
||||||
|
"vitest/no-focused-tests": "error",
|
||||||
|
"vitest/valid-expect": ["error", { maxArgs: 2 }],
|
||||||
|
// it.each cases select different expected outcomes and recovery setup steps.
|
||||||
|
"vitest/no-conditional-expect": "off",
|
||||||
|
// Async test doubles preserve the public Promise contract without artificial awaits.
|
||||||
|
"@typescript-eslint/require-await": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.test.tsx"],
|
||||||
|
plugins: { "testing-library": testingLibrary },
|
||||||
|
// Start with async correctness. DOM structure/geometry tests need direct node access.
|
||||||
|
rules: {
|
||||||
|
"testing-library/await-async-events": "error",
|
||||||
|
"testing-library/await-async-queries": "error",
|
||||||
|
"testing-library/await-async-utils": "error",
|
||||||
|
"testing-library/no-await-sync-events": "error",
|
||||||
|
"testing-library/no-await-sync-queries": "error",
|
||||||
|
"testing-library/no-promise-in-fire-event": "error",
|
||||||
|
"testing-library/no-wait-for-side-effects": "error",
|
||||||
|
"testing-library/no-wait-for-snapshot": "error",
|
||||||
|
"testing-library/prefer-find-by": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storybook.configs["flat/recommended"],
|
||||||
|
{
|
||||||
|
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
|
||||||
|
rules: {
|
||||||
|
"import-x/no-default-export": "off",
|
||||||
|
"import-x/exports-last": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+2444
File diff suppressed because it is too large
Load Diff
+18
-2
@@ -13,15 +13,18 @@
|
|||||||
"preview:catalog": "vite preview --outDir storybook-static",
|
"preview:catalog": "vite preview --outDir storybook-static",
|
||||||
"typecheck": "npm run build:packages && tsc -b --noEmit",
|
"typecheck": "npm run build:packages && tsc -b --noEmit",
|
||||||
"build": "npm run build:packages && tsc -b && vite build",
|
"build": "npm run build:packages && tsc -b && vite build",
|
||||||
"lint": "biome check .",
|
"lint": "npm run lint:biome && npm run lint:eslint",
|
||||||
|
"lint:biome": "biome check .",
|
||||||
|
"lint:eslint": "npm run build:packages && eslint . --max-warnings=0",
|
||||||
"test": "npm run build:packages && vitest",
|
"test": "npm run build:packages && vitest",
|
||||||
"test:run": "npm run build:packages && vitest run",
|
"test:run": "npm run build:packages && vitest run",
|
||||||
"test:coverage": "npm run build:packages && vitest run --coverage",
|
"test:coverage": "npm run build:packages && vitest run --coverage",
|
||||||
|
"test:lint": "node --test scripts/test-eslint.mjs",
|
||||||
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
|
||||||
"openapi:check": "node scripts/check-openapi.mjs",
|
"openapi:check": "node scripts/check-openapi.mjs",
|
||||||
"knip": "npm run build:packages && knip",
|
"knip": "npm run build:packages && knip",
|
||||||
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
|
||||||
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
|
||||||
@@ -62,10 +65,13 @@
|
|||||||
"@biomejs/biome": "2.5.13",
|
"@biomejs/biome": "2.5.13",
|
||||||
"@commitlint/cli": "21.2.2",
|
"@commitlint/cli": "21.2.2",
|
||||||
"@commitlint/config-conventional": "21.2.2",
|
"@commitlint/config-conventional": "21.2.2",
|
||||||
|
"@eslint-react/eslint-plugin": "5.21.0",
|
||||||
|
"@eslint/js": "10.0.1",
|
||||||
"@release-it/conventional-changelog": "12.0.0",
|
"@release-it/conventional-changelog": "12.0.0",
|
||||||
"@rolldown/plugin-babel": "0.2.4",
|
"@rolldown/plugin-babel": "0.2.4",
|
||||||
"@storybook/addon-docs": "10.6.0",
|
"@storybook/addon-docs": "10.6.0",
|
||||||
"@storybook/react-vite": "10.6.0",
|
"@storybook/react-vite": "10.6.0",
|
||||||
|
"@tanstack/eslint-plugin-query": "5.104.0",
|
||||||
"@testing-library/jest-dom": "7.0.1",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/react": "16.3.3",
|
"@testing-library/react": "16.3.3",
|
||||||
"@types/node": "24.13.4",
|
"@types/node": "24.13.4",
|
||||||
@@ -74,9 +80,18 @@
|
|||||||
"@vitejs/plugin-react": "6.1.1",
|
"@vitejs/plugin-react": "6.1.1",
|
||||||
"@vitest/browser-playwright": "5.0.0",
|
"@vitest/browser-playwright": "5.0.0",
|
||||||
"@vitest/coverage-v8": "5.0.0",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
|
"@vitest/eslint-plugin": "1.6.27",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||||
"dependency-cruiser": "18.2.0",
|
"dependency-cruiser": "18.2.0",
|
||||||
|
"eslint": "10.11.0",
|
||||||
|
"eslint-plugin-import-x": "4.17.1",
|
||||||
|
"eslint-plugin-react-hooks": "7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "0.5.7",
|
||||||
|
"eslint-plugin-storybook": "10.6.0",
|
||||||
|
"eslint-plugin-testing-library": "7.16.2",
|
||||||
|
"eslint-plugin-unicorn": "76.0.0",
|
||||||
|
"globals": "17.12.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"jsdom": "30.0.1",
|
"jsdom": "30.0.1",
|
||||||
"knip": "6.35.1",
|
"knip": "6.35.1",
|
||||||
@@ -87,6 +102,7 @@
|
|||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"storybook": "10.6.0",
|
"storybook": "10.6.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.70.1",
|
||||||
"vite": "8.3.0",
|
"vite": "8.3.0",
|
||||||
"vite-plugin-pwa": "1.3.0",
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vitest": "5.0.0"
|
"vitest": "5.0.0"
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import { assert, describe, expect, it } from "vitest";
|
|||||||
import { EntryCard } from "./EntryCard";
|
import { EntryCard } from "./EntryCard";
|
||||||
|
|
||||||
describe("EntryCard", () => {
|
describe("EntryCard", () => {
|
||||||
|
it("does not leak a numeric zero from an absent action", () => {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<EntryCard
|
||||||
|
title="Пример"
|
||||||
|
metadata="Описание"
|
||||||
|
initial="У"
|
||||||
|
value="—"
|
||||||
|
valueLabel="Неизвестно"
|
||||||
|
tone="neutral"
|
||||||
|
actions={0}
|
||||||
|
/>
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
it("keeps a failed image fallback until a new source is supplied", () => {
|
it("keeps a failed image fallback until a new source is supplied", () => {
|
||||||
const props = {
|
const props = {
|
||||||
title: "Пример",
|
title: "Пример",
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
|
|||||||
>
|
>
|
||||||
{props.valueLabel}
|
{props.valueLabel}
|
||||||
</Text>
|
</Text>
|
||||||
{props.actions && (
|
{Boolean(props.actions) && (
|
||||||
<div className={classes.actions}>{props.actions}</div>
|
<div className={classes.actions}>{props.actions}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { ESLint } from "eslint";
|
||||||
|
|
||||||
|
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
|
||||||
|
const eslint = new ESLint();
|
||||||
|
for (const [filePath, code, rule] of [
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"export function run() { Promise.resolve(); }",
|
||||||
|
"@typescript-eslint/no-floating-promises",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"packages/ui/src/EntryCard/EntryCard.tsx",
|
||||||
|
'import { useState } from "react"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }',
|
||||||
|
"react-hooks/rules-of-hooks",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"export const values = Array(3).fill({});",
|
||||||
|
"unicorn/no-array-fill-with-reference-type",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/shared/api/problem-details.ts",
|
||||||
|
"export default 1;",
|
||||||
|
"import-x/no-default-export",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"src/shared/api/client.test.ts",
|
||||||
|
'import { it } from "vitest"; it.only("focused", () => {});',
|
||||||
|
"vitest/no-focused-tests",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"packages/ui/src/EntryCard/EntryCard.test.tsx",
|
||||||
|
'import { screen } from "@testing-library/react"; screen.findByText("ready");',
|
||||||
|
"testing-library/await-async-queries",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"packages/ui/src/EntryCard/EntryCard.tsx",
|
||||||
|
'import { QueryClient } from "@tanstack/react-query"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }',
|
||||||
|
"@tanstack/query/stable-query-client",
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0, filePath);
|
||||||
|
assert.ok(
|
||||||
|
result.messages.some((message) => message.ruleId === rule),
|
||||||
|
rule,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of [
|
||||||
|
"src/shared/api/generated/schema.d.ts",
|
||||||
|
"packages/ui/dist/index.js",
|
||||||
|
"storybook-static/index.js",
|
||||||
|
"coverage/index.js",
|
||||||
|
])
|
||||||
|
assert.equal(await eslint.isPathIgnored(file), true, file);
|
||||||
|
const story = await eslint.calculateConfigForFile(
|
||||||
|
"src/shared/ui/Button.stories.tsx",
|
||||||
|
);
|
||||||
|
assert.equal(story.rules["import-x/no-default-export"][0], 0);
|
||||||
|
assert.ok(story.rules["storybook/default-exports"][0] > 0);
|
||||||
|
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { MemoryRouter } from "react-router";
|
import { MemoryRouter } from "react-router";
|
||||||
@@ -41,22 +41,18 @@ describe("AppRoutes", () => {
|
|||||||
])("renders %s with its page title", async (path, title) => {
|
])("renders %s with its page title", async (path, title) => {
|
||||||
renderRoutes(path);
|
renderRoutes(path);
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
await screen.findByRole("heading", { level: 1, name: title }),
|
||||||
screen.getByRole("heading", { level: 1, name: title }),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(document.title).toBe(`${title} — WealthLedger`);
|
expect(document.title).toBe(`${title} — WealthLedger`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects anonymous users from accounts", async () => {
|
it("redirects anonymous users from accounts", async () => {
|
||||||
renderRoutes("/accounts");
|
renderRoutes("/accounts");
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
await screen.findByRole("heading", { level: 1, name: "Вход" }),
|
||||||
screen.getByRole("heading", { level: 1, name: "Вход" }),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs in through the public form and reaches the protected landing", async () => {
|
it("logs in through the public form and reaches the protected landing", async () => {
|
||||||
@@ -142,14 +138,12 @@ describe("AppRoutes", () => {
|
|||||||
);
|
);
|
||||||
renderRoutes("/accounts");
|
renderRoutes("/accounts");
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
await screen.findByRole("heading", {
|
||||||
screen.getByRole("heading", {
|
level: 2,
|
||||||
level: 2,
|
name: "Нет инвестиционных счетов",
|
||||||
name: "Нет инвестиционных счетов",
|
}),
|
||||||
}),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("navigation", { name: "WealthLedger" }),
|
screen.getByRole("navigation", { name: "WealthLedger" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { type FC, lazy, Suspense } from "react";
|
import { type FC, lazy, Suspense } from "react";
|
||||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
@@ -135,8 +135,6 @@ describe("RouteErrorBoundary", () => {
|
|||||||
authenticated = true;
|
authenticated = true;
|
||||||
renderBoundary("/accounts");
|
renderBoundary("/accounts");
|
||||||
screen.getByRole("button", { name: "go healthy" }).click();
|
screen.getByRole("button", { name: "go healthy" }).click();
|
||||||
await waitFor(() =>
|
expect(await screen.findByText("healthy route")).toBeInTheDocument();
|
||||||
expect(screen.getByText("healthy route")).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
|
|||||||
if (!value || value === accountId) return;
|
if (!value || value === accountId) return;
|
||||||
const filters = new URLSearchParams(search);
|
const filters = new URLSearchParams(search);
|
||||||
filters.delete("cursor");
|
filters.delete("cursor");
|
||||||
navigate({
|
void navigate({
|
||||||
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`,
|
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`,
|
||||||
search: filters.toString(),
|
search: filters.toString(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ async function mutationData<T>(
|
|||||||
request: Promise<{ data?: T; error?: unknown }>,
|
request: Promise<{ data?: T; error?: unknown }>,
|
||||||
) {
|
) {
|
||||||
const { data, error } = await request;
|
const { data, error } = await request;
|
||||||
|
// 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;
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ function useBankOperations(
|
|||||||
"/api/v1/bank-cards/{id}/operations",
|
"/api/v1/bank-cards/{id}/operations",
|
||||||
{ params },
|
{ params },
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
|
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
|
||||||
placeholderData: (
|
placeholderData: (
|
||||||
@@ -108,6 +109,7 @@ function useBankOperations(
|
|||||||
signal,
|
signal,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// 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;
|
||||||
if (!data) throw new Error("Missing bank operations response");
|
if (!data) throw new Error("Missing bank operations response");
|
||||||
if (!all) return data;
|
if (!all) return data;
|
||||||
|
|||||||
@@ -101,28 +101,30 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<form
|
<form
|
||||||
onSubmit={methods.handleSubmit(async (values) => {
|
onSubmit={(event) => {
|
||||||
setError(undefined);
|
void methods.handleSubmit(async (values) => {
|
||||||
try {
|
setError(undefined);
|
||||||
if (editing)
|
try {
|
||||||
await mutation.updateCategory.mutateAsync({
|
if (editing)
|
||||||
id: editing.id,
|
await mutation.updateCategory.mutateAsync({
|
||||||
body: { name: values.name, version: editing.version },
|
id: editing.id,
|
||||||
});
|
body: { name: values.name, version: editing.version },
|
||||||
else {
|
});
|
||||||
const result = await mutation.createCategory.mutateAsync({
|
else {
|
||||||
name: values.name,
|
const result = await mutation.createCategory.mutateAsync({
|
||||||
});
|
name: values.name,
|
||||||
if (result) props.onCreated?.(result.id);
|
});
|
||||||
|
if (result) props.onCreated?.(result.id);
|
||||||
|
}
|
||||||
|
editSession.current++;
|
||||||
|
setEditing(undefined);
|
||||||
|
setLatest(undefined);
|
||||||
|
methods.reset({ name: "" });
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure);
|
||||||
}
|
}
|
||||||
editSession.current++;
|
})(event);
|
||||||
setEditing(undefined);
|
}}
|
||||||
setLatest(undefined);
|
|
||||||
methods.reset({ name: "" });
|
|
||||||
} catch (failure) {
|
|
||||||
setError(failure);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<fieldset disabled={pending} className={classes.fieldset}>
|
<fieldset disabled={pending} className={classes.fieldset}>
|
||||||
<Stack>
|
<Stack>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ describe("persistent resource query lifecycle", () => {
|
|||||||
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
"/api/v1/brokerage-accounts/{accountId}/portfolio",
|
||||||
{ params: { path: { accountId: "a" } } },
|
{ params: { path: { accountId: "a" } } },
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||||
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
|
||||||
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
|
||||||
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
params: { path: { accountId: "a" }, query: { sync: "observe" } },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { AuthProvider, useAuth } from "./model/auth";
|
export { AuthProvider } from "./model/auth";
|
||||||
|
export { useAuth } from "./model/auth-context";
|
||||||
export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout";
|
export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout";
|
||||||
export { LoginForm } from "./ui/LoginForm";
|
export { LoginForm } from "./ui/LoginForm";
|
||||||
export { RegisterForm } from "./ui/RegisterForm";
|
export { RegisterForm } from "./ui/RegisterForm";
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { paths } from "@/shared/api";
|
||||||
|
|
||||||
|
type User =
|
||||||
|
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: User | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
ready: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
register: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refresh: () => Promise<boolean>;
|
||||||
|
};
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) throw new Error("AuthProvider is required");
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AuthContext, type User, useAuth };
|
||||||
@@ -7,7 +7,8 @@ import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
|
|||||||
import { createTestQueryClient } from "@/test/query";
|
import { createTestQueryClient } from "@/test/query";
|
||||||
import { resource } from "@/test/resource";
|
import { resource } from "@/test/resource";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { AuthProvider, useAuth } from "./auth";
|
import { AuthProvider } from "./auth";
|
||||||
|
import { useAuth } from "./auth-context";
|
||||||
|
|
||||||
const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
|
const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
|
||||||
params: { query: { includeArchived: "false" } },
|
params: { query: { includeArchived: "false" } },
|
||||||
|
|||||||
@@ -1,32 +1,13 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import type { FC, PropsWithChildren } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import {
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
createContext,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
||||||
|
import { AuthContext, type User } from "./auth-context";
|
||||||
|
|
||||||
type User =
|
|
||||||
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
|
||||||
type Authentication =
|
type Authentication =
|
||||||
paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
||||||
type Credentials =
|
type Credentials =
|
||||||
paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"];
|
paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"];
|
||||||
type AuthContextValue = {
|
|
||||||
user: User | null;
|
|
||||||
accessToken: string | null;
|
|
||||||
ready: boolean;
|
|
||||||
login: (email: string, password: string) => Promise<void>;
|
|
||||||
register: (email: string, password: string) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
refresh: () => Promise<boolean>;
|
|
||||||
};
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
||||||
|
|
||||||
async function request(
|
async function request(
|
||||||
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
||||||
email: string,
|
email: string,
|
||||||
@@ -35,8 +16,9 @@ async function request(
|
|||||||
const { data, error } = await apiClient.POST(path, {
|
const { data, error } = await apiClient.POST(path, {
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
} satisfies { body: Credentials });
|
} satisfies { body: Credentials });
|
||||||
|
// 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;
|
||||||
return data as Authentication;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthProvider: FC<PropsWithChildren> = (props) => {
|
const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||||
@@ -165,10 +147,4 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function useAuth() {
|
export { AuthProvider };
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) throw new Error("AuthProvider is required");
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { AuthProvider, useAuth };
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ describe("AuthForm", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Регистрация" }));
|
fireEvent.click(screen.getByRole("button", { name: "Регистрация" }));
|
||||||
|
|
||||||
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
|
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
|
||||||
await waitFor(() =>
|
expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Пароли не совпадают")).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../model/auth";
|
import { useAuth } from "../model/auth-context";
|
||||||
import { AuthForm } from "./AuthForm/AuthForm";
|
import { AuthForm } from "./AuthForm/AuthForm";
|
||||||
|
|
||||||
const LoginForm: FC = () => {
|
const LoginForm: FC = () => {
|
||||||
@@ -14,7 +14,7 @@ const LoginForm: FC = () => {
|
|||||||
<AuthForm
|
<AuthForm
|
||||||
onSubmit={async (email, password) => {
|
onSubmit={async (email, password) => {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate(returnTo, { replace: true });
|
await navigate(returnTo, { replace: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../model/auth";
|
import { useAuth } from "../model/auth-context";
|
||||||
import { AuthForm } from "./AuthForm/AuthForm";
|
import { AuthForm } from "./AuthForm/AuthForm";
|
||||||
|
|
||||||
const RegisterForm: FC = () => {
|
const RegisterForm: FC = () => {
|
||||||
@@ -15,7 +15,7 @@ const RegisterForm: FC = () => {
|
|||||||
register
|
register
|
||||||
onSubmit={async (email, password) => {
|
onSubmit={async (email, password) => {
|
||||||
await register(email, password);
|
await register(email, password);
|
||||||
navigate(returnTo, { replace: true });
|
await navigate(returnTo, { replace: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,57 @@ function auth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
describe("page-owned screen synchronization", () => {
|
describe("page-owned screen synchronization", () => {
|
||||||
|
it("ignores a late sync acceptance after the account scope changes", async () => {
|
||||||
|
auth();
|
||||||
|
let release!: () => void;
|
||||||
|
const blocked = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const posted: string[] = [];
|
||||||
|
const polled: string[] = [];
|
||||||
|
server.use(
|
||||||
|
http.post("*/api/v1/tbank-sync", async ({ request }) => {
|
||||||
|
const scope = (await request.json()) as {
|
||||||
|
screen: "account";
|
||||||
|
accountId: string;
|
||||||
|
};
|
||||||
|
posted.push(scope.accountId);
|
||||||
|
await blocked;
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ id: scope.accountId, scope, now: "2026-09-25T00:00:00Z" },
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
http.get("*/api/v1/tbank-sync/:id", ({ params }) => {
|
||||||
|
polled.push(String(params.id));
|
||||||
|
return HttpResponse.json({
|
||||||
|
id: params.id,
|
||||||
|
state: "succeeded",
|
||||||
|
resources: [],
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ accountId }) => useScreenSync({ screen: "account", accountId }, []),
|
||||||
|
{
|
||||||
|
initialProps: { accountId: "old" },
|
||||||
|
wrapper: createQueryWrapper().wrapper,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let pending!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
pending = result.current.refresh();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(posted).toEqual(["old"]));
|
||||||
|
rerender({ accountId: "new" });
|
||||||
|
await act(async () => {
|
||||||
|
release();
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
await act(() => result.current.refresh());
|
||||||
|
await waitFor(() => expect(polled).toEqual(["new"]));
|
||||||
|
expect(posted).toEqual(["old", "new"]);
|
||||||
|
});
|
||||||
it.each([false, true])(
|
it.each([false, true])(
|
||||||
"retries a failed GET without forcing the source (cached=%s)",
|
"retries a failed GET without forcing the source (cached=%s)",
|
||||||
async (cached) => {
|
async (cached) => {
|
||||||
@@ -155,7 +206,7 @@ describe("page-owned screen synchronization", () => {
|
|||||||
);
|
);
|
||||||
expect(published).not.toContainEqual(
|
expect(published).not.toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
meta: expect.objectContaining({ stateVersion: "old" }),
|
meta: expect.objectContaining({ stateVersion: "old" }) as unknown,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
@@ -354,7 +405,7 @@ describe("page-owned screen synchronization", () => {
|
|||||||
});
|
});
|
||||||
expect(modes).toEqual([null, "observe"]);
|
expect(modes).toEqual([null, "observe"]);
|
||||||
if (boundary === "leave") unmount();
|
if (boundary === "leave") unmount();
|
||||||
else await act(() => configureAuthClient(null));
|
else act(() => configureAuthClient(null));
|
||||||
const stopped = polls;
|
const stopped = polls;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
apiErrorCode,
|
apiErrorCode,
|
||||||
apiQueryClient,
|
apiQueryClient,
|
||||||
@@ -33,7 +33,9 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const requestInFlight = useRef<string | null>(null);
|
const requestInFlight = useRef<string | null>(null);
|
||||||
const currentScope = useRef(scopeKey);
|
const currentScope = useRef(scopeKey);
|
||||||
currentScope.current = scopeKey;
|
useLayoutEffect(() => {
|
||||||
|
currentScope.current = scopeKey;
|
||||||
|
}, [scopeKey]);
|
||||||
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
|
||||||
const observed = useRef(new Map<string, string>());
|
const observed = useRef(new Map<string, string>());
|
||||||
const ids = [
|
const ids = [
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ describe("sync connection and session boundaries", () => {
|
|||||||
const { wrapper, queryClient } = createQueryWrapper();
|
const { wrapper, queryClient } = createQueryWrapper();
|
||||||
const published: string[] = [];
|
const published: string[] = [];
|
||||||
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
|
||||||
if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") {
|
if (
|
||||||
|
(event.query.queryKey as readonly unknown[])[1] ===
|
||||||
|
"/api/v1/tbank-sync/{id}"
|
||||||
|
) {
|
||||||
const data = event.query.state.data as { now?: string } | undefined;
|
const data = event.query.state.data as { now?: string } | undefined;
|
||||||
if (data?.now) published.push(data.now);
|
if (data?.now) published.push(data.now);
|
||||||
}
|
}
|
||||||
@@ -73,6 +76,7 @@ describe("sync connection and session boundaries", () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
mutation = result.current.mutation.mutateAsync({ body: screen });
|
mutation = result.current.mutation.mutateAsync({ body: screen });
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||||
const discarded = expect(mutation).rejects.toMatchObject({
|
const discarded = expect(mutation).rejects.toMatchObject({
|
||||||
name: "AbortError",
|
name: "AbortError",
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-10
@@ -163,11 +163,9 @@ describe("TbankConnectionSection", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
|
fireEvent.click(screen.getByRole("button", { name: "Подключить" }));
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
await screen.findByRole("button", { name: "Заменить token" }),
|
||||||
screen.getByRole("button", { name: "Заменить token" }),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
|
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
|
||||||
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
|
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
|
||||||
{ id: "bank-card-1" },
|
{ id: "bank-card-1" },
|
||||||
@@ -224,11 +222,9 @@ describe("TbankConnectionSection", () => {
|
|||||||
);
|
);
|
||||||
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
|
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
|
||||||
|
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
await screen.findByRole("button", { name: "Подключить" }),
|
||||||
screen.getByRole("button", { name: "Подключить" }),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
|
expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined();
|
||||||
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
|
expect(client.getQueryData(bankCardsQueryKey)).toEqual([
|
||||||
{ id: "bank-card-1" },
|
{ id: "bank-card-1" },
|
||||||
|
|||||||
@@ -48,10 +48,15 @@ function useAccountEventsPageViewModel(
|
|||||||
normalized.delete("to");
|
normalized.delete("to");
|
||||||
setSearchParams(normalized, { replace: true });
|
setSearchParams(normalized, { replace: true });
|
||||||
}, [validUrlPeriod, searchParams, setSearchParams]);
|
}, [validUrlPeriod, searchParams, setSearchParams]);
|
||||||
useEffect(() => {
|
const [previousPeriod, setPreviousPeriod] = useState([
|
||||||
|
appliedFrom,
|
||||||
|
appliedTo,
|
||||||
|
]);
|
||||||
|
if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) {
|
||||||
|
setPreviousPeriod([appliedFrom, appliedTo]);
|
||||||
setDraftFrom(appliedFrom);
|
setDraftFrom(appliedFrom);
|
||||||
setDraftTo(appliedTo);
|
setDraftTo(appliedTo);
|
||||||
}, [appliedFrom, appliedTo]);
|
}
|
||||||
const events = useBrokerageEvents(accountId, {
|
const events = useBrokerageEvents(accountId, {
|
||||||
...(appliedFrom ? { from: appliedFrom } : {}),
|
...(appliedFrom ? { from: appliedFrom } : {}),
|
||||||
...(appliedTo ? { to: appliedTo } : {}),
|
...(appliedTo ? { to: appliedTo } : {}),
|
||||||
|
|||||||
@@ -462,11 +462,9 @@ describe("AccountIncomePage", () => {
|
|||||||
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
await screen.findByRole("heading", { name: "Пассивный доход" }),
|
||||||
screen.getByRole("heading", { name: "Пассивный доход" }),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Налоговые операции не включены в этот результат."),
|
screen.getByText("Налоговые операции не включены в этот результат."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -542,11 +540,9 @@ describe("AccountIncomePage", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
renderPage();
|
renderPage();
|
||||||
await waitFor(() =>
|
expect(
|
||||||
expect(
|
await screen.findByRole("button", { name: "Повторить" }),
|
||||||
screen.getByRole("button", { name: "Повторить" }),
|
).toBeInTheDocument();
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,18 +43,22 @@ type IncomeDisplayModel = {
|
|||||||
|
|
||||||
type AccountIncomePageViewModel =
|
type AccountIncomePageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; retry: () => void; period: IncomePeriod }
|
| {
|
||||||
|
state: "error";
|
||||||
|
retry: () => undefined | Promise<unknown>;
|
||||||
|
period: IncomePeriod;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
analytics: IncomeAnalytics;
|
analytics: IncomeAnalytics;
|
||||||
display: IncomeDisplayModel;
|
display: IncomeDisplayModel;
|
||||||
formRevision: number;
|
formRevision: string;
|
||||||
sourceError?: boolean;
|
sourceError?: boolean;
|
||||||
lastSuccessfulAt?: string | null;
|
lastSuccessfulAt?: string | null;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
refreshError: boolean;
|
refreshError: boolean;
|
||||||
retry: () => void;
|
retry: () => undefined | Promise<unknown>;
|
||||||
form: ReturnType<typeof useForm<IncomeForm>>;
|
form: ReturnType<typeof useForm<IncomeForm>>;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onSubmit: (values: IncomeForm) => void;
|
onSubmit: (values: IncomeForm) => void;
|
||||||
@@ -102,7 +106,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
defaultValues: draftPeriod(period),
|
defaultValues: draftPeriod(period),
|
||||||
resolver: zodResolver(incomeFormSchema),
|
resolver: zodResolver(incomeFormSchema),
|
||||||
});
|
});
|
||||||
const [formRevision, setFormRevision] = useState(0);
|
const [resetRevision, setResetRevision] = useState(0);
|
||||||
const analytics = useIncomeAnalytics(accountId, period);
|
const analytics = useIncomeAnalytics(accountId, period);
|
||||||
const sync = useScreenSync(
|
const sync = useScreenSync(
|
||||||
{ screen: "income", accountId, from: period.from, to: period.to },
|
{ screen: "income", accountId, from: period.from, to: period.to },
|
||||||
@@ -114,7 +118,6 @@ const useAccountIncomePageViewModel = (
|
|||||||
}, [period, requested, setSearchParams]);
|
}, [period, requested, setSearchParams]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset(draftPeriod(period));
|
form.reset(draftPeriod(period));
|
||||||
setFormRevision((revision) => revision + 1);
|
|
||||||
}, [form, period]);
|
}, [form, period]);
|
||||||
|
|
||||||
const onSubmit = (values: IncomeForm) => {
|
const onSubmit = (values: IncomeForm) => {
|
||||||
@@ -126,7 +129,7 @@ const useAccountIncomePageViewModel = (
|
|||||||
const next = defaultPeriod();
|
const next = defaultPeriod();
|
||||||
form.reset(draftPeriod(next));
|
form.reset(draftPeriod(next));
|
||||||
// Reset the open calendar along with the form.
|
// Reset the open calendar along with the form.
|
||||||
setFormRevision((revision) => revision + 1);
|
setResetRevision((revision) => revision + 1);
|
||||||
setSearchParams(next, { replace: true });
|
setSearchParams(next, { replace: true });
|
||||||
};
|
};
|
||||||
if (analytics.isLoading || !analytics.data) {
|
if (analytics.isLoading || !analytics.data) {
|
||||||
@@ -138,16 +141,14 @@ const useAccountIncomePageViewModel = (
|
|||||||
if (!display)
|
if (!display)
|
||||||
return {
|
return {
|
||||||
state: "error",
|
state: "error",
|
||||||
retry: () => {
|
retry: () => analytics.observe(),
|
||||||
void analytics.observe();
|
|
||||||
},
|
|
||||||
period,
|
period,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state: "loaded",
|
state: "loaded",
|
||||||
analytics: analytics.data,
|
analytics: analytics.data,
|
||||||
display,
|
display,
|
||||||
formRevision,
|
formRevision: `${period.from}:${period.to}:${resetRevision}`,
|
||||||
sourceError: sync.sourceError,
|
sourceError: sync.sourceError,
|
||||||
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
|
||||||
partial: analytics.meta?.availability !== "complete",
|
partial: analytics.meta?.availability !== "complete",
|
||||||
|
|||||||
+14
-11
@@ -22,8 +22,14 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
|||||||
control,
|
control,
|
||||||
formState: { isSubmitting },
|
formState: { isSubmitting },
|
||||||
} = useFormContext<IncomeForm>();
|
} = useFormContext<IncomeForm>();
|
||||||
const fromField = useController({ control, name: "from" });
|
const {
|
||||||
const toField = useController({ control, name: "to" });
|
field: { ref: fromRef, onChange: onFromChange, onBlur: onFromBlur },
|
||||||
|
fieldState: { error: fromError },
|
||||||
|
} = useController({ control, name: "from" });
|
||||||
|
const {
|
||||||
|
field: { onChange: onToChange, onBlur: onToBlur },
|
||||||
|
fieldState: { error: toError },
|
||||||
|
} = useController({ control, name: "to" });
|
||||||
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
const [from, to] = useWatch({ control, name: ["from", "to"] });
|
||||||
const today = moscowDate(new Date());
|
const today = moscowDate(new Date());
|
||||||
const start = parseDraftDate(from);
|
const start = parseDraftDate(from);
|
||||||
@@ -44,23 +50,20 @@ const IncomePeriodForm: FC<IncomePeriodFormProps> = (props) => {
|
|||||||
allowSingleDateInRange
|
allowSingleDateInRange
|
||||||
clearable
|
clearable
|
||||||
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
|
||||||
ref={fromField.field.ref}
|
ref={fromRef}
|
||||||
label={t("income.period")}
|
label={t("income.period")}
|
||||||
valueFormat="DD.MM.YYYY"
|
valueFormat="DD.MM.YYYY"
|
||||||
value={[start, end]}
|
value={[start, end]}
|
||||||
onChange={([from, to]) => {
|
onChange={([from, to]) => {
|
||||||
fromField.field.onChange(from ? formatDraftDate(from) : "");
|
onFromChange(from ? formatDraftDate(from) : "");
|
||||||
toField.field.onChange(to ? formatDraftDate(to) : "");
|
onToChange(to ? formatDraftDate(to) : "");
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
fromField.field.onBlur();
|
onFromBlur();
|
||||||
toField.field.onBlur();
|
onToBlur();
|
||||||
}}
|
}}
|
||||||
maxDate={today}
|
maxDate={today}
|
||||||
error={
|
error={fromError?.message ?? toError?.message}
|
||||||
fromField.fieldState.error?.message ??
|
|
||||||
toField.fieldState.error?.message
|
|
||||||
}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
popoverProps={{
|
popoverProps={{
|
||||||
returnFocus: true,
|
returnFocus: true,
|
||||||
|
|||||||
@@ -72,11 +72,9 @@ const useAccountOperationsPageVm = (
|
|||||||
}>({ scopeKey, cursors: [] });
|
}>({ scopeKey, cursors: [] });
|
||||||
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
|
||||||
const cursor = cursors.at(-1);
|
const cursor = cursors.at(-1);
|
||||||
useEffect(() => {
|
if (pagination.scopeKey !== scopeKey) {
|
||||||
setPagination((current) =>
|
setPagination({ scopeKey, cursors: [] });
|
||||||
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
|
}
|
||||||
);
|
|
||||||
}, [scopeKey]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (validUrlPeriod) return;
|
if (validUrlPeriod) return;
|
||||||
const normalized = new URLSearchParams(searchParams);
|
const normalized = new URLSearchParams(searchParams);
|
||||||
@@ -84,7 +82,9 @@ const useAccountOperationsPageVm = (
|
|||||||
normalized.delete("to");
|
normalized.delete("to");
|
||||||
setSearchParams(normalized, { replace: true });
|
setSearchParams(normalized, { replace: true });
|
||||||
}, [validUrlPeriod, searchParams, setSearchParams]);
|
}, [validUrlPeriod, searchParams, setSearchParams]);
|
||||||
useEffect(() => {
|
const [previousSearch, setPreviousSearch] = useState(searchParams);
|
||||||
|
if (previousSearch !== searchParams) {
|
||||||
|
setPreviousSearch(searchParams);
|
||||||
setDraftFrom(appliedFrom);
|
setDraftFrom(appliedFrom);
|
||||||
setDraftTo(appliedTo);
|
setDraftTo(appliedTo);
|
||||||
setDraftCategories(
|
setDraftCategories(
|
||||||
@@ -92,7 +92,7 @@ const useAccountOperationsPageVm = (
|
|||||||
(searchParams.get("categories") ?? "").split(",").includes(category),
|
(searchParams.get("categories") ?? "").split(",").includes(category),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, [appliedFrom, appliedTo, searchParams]);
|
}
|
||||||
const firstPageQuery = {
|
const firstPageQuery = {
|
||||||
from: dateStart(appliedFrom),
|
from: dateStart(appliedFrom),
|
||||||
to: dateEnd(appliedTo),
|
to: dateEnd(appliedTo),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ type AccountPageRecentEvents = BrokerageEventsPreviewState;
|
|||||||
|
|
||||||
type AccountPageIncomeSummary =
|
type AccountPageIncomeSummary =
|
||||||
| { state: "loading" | "partial" | "empty"; to: string }
|
| { state: "loading" | "partial" | "empty"; to: string }
|
||||||
| { state: "error"; to: string; retry: () => void }
|
| { state: "error"; to: string; retry: () => undefined | Promise<unknown> }
|
||||||
| {
|
| {
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
to: string;
|
to: string;
|
||||||
@@ -75,7 +75,7 @@ type AccountPageContent = {
|
|||||||
accountId?: string;
|
accountId?: string;
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
refreshError: boolean;
|
refreshError: boolean;
|
||||||
retry: () => void;
|
retry: () => undefined | Promise<unknown>;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
recentEvents: AccountPageRecentEvents;
|
recentEvents: AccountPageRecentEvents;
|
||||||
incomeSummary: AccountPageIncomeSummary;
|
incomeSummary: AccountPageIncomeSummary;
|
||||||
@@ -93,7 +93,7 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
|||||||
|
|
||||||
type AccountPageViewModel =
|
type AccountPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; code?: string; retry: () => void }
|
| { state: "error"; code?: string; retry: () => undefined | Promise<unknown> }
|
||||||
| AccountPageEmptyViewModel
|
| AccountPageEmptyViewModel
|
||||||
| AccountPageLoadedViewModel;
|
| AccountPageLoadedViewModel;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
@@ -157,12 +157,12 @@ const useOperationFormVm = (props: OperationFormProps) => {
|
|||||||
description: props.operation?.description ?? "",
|
description: props.operation?.description ?? "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const kind = methods.watch("kind");
|
const kind = useWatch({ control: methods.control, name: "kind" });
|
||||||
const expenses = useBankExpenses(
|
const expenses = useBankExpenses(
|
||||||
kind === "refund" ? props.card.id : undefined,
|
kind === "refund" ? props.card.id : undefined,
|
||||||
{ page: sourcePage },
|
{ page: sourcePage },
|
||||||
);
|
);
|
||||||
const source = methods.watch("cardId");
|
const source = useWatch({ control: methods.control, name: "cardId" });
|
||||||
const pending = !opened || methods.formState.isSubmitting;
|
const pending = !opened || methods.formState.isSubmitting;
|
||||||
const options = props.cards
|
const options = props.cards
|
||||||
.filter((c) => !c.archivedAt)
|
.filter((c) => !c.archivedAt)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SettingsPage: FC = () => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await logout();
|
await logout();
|
||||||
navigate("/login", { replace: true });
|
await navigate("/login", { replace: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("auth.logout")}
|
{t("auth.logout")}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ describe("apiClient authentication", () => {
|
|||||||
refresh: async () => true,
|
refresh: async () => true,
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
});
|
});
|
||||||
|
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
|
||||||
const response = expect(
|
const response = expect(
|
||||||
apiClient.GET("/api/v1/brokerage-accounts"),
|
apiClient.GET("/api/v1/brokerage-accounts"),
|
||||||
).rejects.toMatchObject({ name: "AbortError" });
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import createClient from "openapi-fetch";
|
|||||||
import createQueryClient from "openapi-react-query";
|
import createQueryClient from "openapi-react-query";
|
||||||
|
|
||||||
import type { paths } from "./generated/schema";
|
import type { paths } from "./generated/schema";
|
||||||
|
import { apiErrorCode } from "./problem-details";
|
||||||
|
|
||||||
const HTTP_UNAUTHORIZED = 401;
|
const HTTP_UNAUTHORIZED = 401;
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000";
|
const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
const apiBaseUrl =
|
||||||
|
typeof configuredBaseUrl === "string"
|
||||||
|
? configuredBaseUrl
|
||||||
|
: "http://localhost:3000";
|
||||||
|
|
||||||
type AuthHandlers = {
|
type AuthHandlers = {
|
||||||
generation?: () => number;
|
generation?: () => number;
|
||||||
@@ -105,12 +110,12 @@ async function fetchCurrent(
|
|||||||
response.status !== HTTP_UNAUTHORIZED ||
|
response.status !== HTTP_UNAUTHORIZED ||
|
||||||
isAuthRequest ||
|
isAuthRequest ||
|
||||||
!auth ||
|
!auth ||
|
||||||
(
|
apiErrorCode(
|
||||||
await response
|
await response
|
||||||
.clone()
|
.clone()
|
||||||
.json()
|
.json()
|
||||||
.catch(() => null)
|
.catch(() => null),
|
||||||
)?.code !== "UNAUTHORIZED"
|
) !== "UNAUTHORIZED"
|
||||||
)
|
)
|
||||||
return response;
|
return response;
|
||||||
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const Primary: Story = {
|
|||||||
name: "Основное действие",
|
name: "Основное действие",
|
||||||
play: async ({ canvasElement, args }) => {
|
play: async ({ canvasElement, args }) => {
|
||||||
const button = within(canvasElement).getByRole("button", {
|
const button = within(canvasElement).getByRole("button", {
|
||||||
name: String(args.children),
|
name: typeof args.children === "string" ? args.children : undefined,
|
||||||
});
|
});
|
||||||
button.focus();
|
button.focus();
|
||||||
await expect(button).toHaveFocus();
|
await expect(button).toHaveFocus();
|
||||||
|
|||||||
Reference in New Issue
Block a user