feat: enforce frontend quality guardrails
This commit is contained in:
@@ -1,22 +1,43 @@
|
||||
module.exports = {
|
||||
forbidden: [
|
||||
{
|
||||
name: "no-circular",
|
||||
severity: "error",
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
{
|
||||
name: "slices-must-use-public-api",
|
||||
severity: "error",
|
||||
from: {
|
||||
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
|
||||
},
|
||||
to: {
|
||||
path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)",
|
||||
pathNot: "^src/$1/$2/",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shared-must-not-depend-on-layers",
|
||||
severity: "error",
|
||||
from: { path: "^src/shared/" },
|
||||
to: { path: "^src/(entities|features|pages|app)/" },
|
||||
},
|
||||
{
|
||||
name: "entities-must-not-depend-on-higher-layers",
|
||||
severity: "error",
|
||||
from: { path: "^src/entities/" },
|
||||
to: { path: "^src/(features|pages|app)/" },
|
||||
},
|
||||
{
|
||||
name: "features-must-not-depend-on-higher-layers",
|
||||
severity: "error",
|
||||
from: { path: "^src/features/" },
|
||||
to: { path: "^src/(pages|app)/" },
|
||||
},
|
||||
{
|
||||
name: "pages-must-not-depend-on-app",
|
||||
severity: "error",
|
||||
from: { path: "^src/pages/" },
|
||||
to: { path: "^src/app/" },
|
||||
},
|
||||
|
||||
+4
-2
@@ -19,8 +19,10 @@
|
||||
"openapi:check": "node scripts/openapi-check.mjs",
|
||||
"hygiene:check": "node scripts/repository-hygiene-check.mjs",
|
||||
"knip": "knip",
|
||||
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
|
||||
"verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build",
|
||||
"deps": "node --test scripts/dependency-cruiser-config.test.mjs && depcruise src --config dependency-cruiser.cjs --output-type err-long",
|
||||
"bundle:check": "node --test scripts/bundle-budget.test.mjs && node scripts/bundle-budget.mjs",
|
||||
"bundle:baseline": "npm run build && node scripts/bundle-budget.mjs baseline",
|
||||
"verify": "npm run openapi:check && npm run hygiene:check && npm run typecheck && npm run lint && npm run test:run && npm run test:coverage && npm run knip && npm run deps && npm run build && npm run bundle:check",
|
||||
"prepare": "husky",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024;
|
||||
|
||||
function manifestEntries(manifest) {
|
||||
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
||||
throw new Error("Vite manifest must be an object");
|
||||
}
|
||||
return Object.entries(manifest);
|
||||
}
|
||||
|
||||
function assetFile(entry, entryName) {
|
||||
if (!entry || typeof entry.file !== "string" || !entry.file.endsWith(".js")) {
|
||||
throw new Error(
|
||||
`Manifest entry '${entryName}' must reference a JavaScript file`,
|
||||
);
|
||||
}
|
||||
return entry.file;
|
||||
}
|
||||
|
||||
export function getInitialAssetFiles(manifest) {
|
||||
const entries = manifestEntries(manifest);
|
||||
const entryCandidates = entries.filter(
|
||||
([, entry]) => entry?.isEntry === true,
|
||||
);
|
||||
if (entryCandidates.length !== 1) {
|
||||
throw new Error("Vite manifest must contain exactly one JavaScript entry");
|
||||
}
|
||||
|
||||
const files = [];
|
||||
const visitedEntries = new Set();
|
||||
const visit = (entryName) => {
|
||||
if (visitedEntries.has(entryName)) return;
|
||||
visitedEntries.add(entryName);
|
||||
const entry = manifest[entryName];
|
||||
if (!entry)
|
||||
throw new Error(`Vite manifest is missing eager import '${entryName}'`);
|
||||
files.push(assetFile(entry, entryName));
|
||||
for (const importedEntry of entry.imports ?? []) visit(importedEntry);
|
||||
};
|
||||
|
||||
visit(entryCandidates[0][0]);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function measureAssets(files, getSize) {
|
||||
return files.reduce((total, file) => {
|
||||
const size = getSize(file);
|
||||
if (!Number.isSafeInteger(size) || size < 0) {
|
||||
throw new Error(`Cannot measure bundle asset '${file}'`);
|
||||
}
|
||||
return total + size;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function assertBundleBudget(initialBytes) {
|
||||
if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) {
|
||||
throw new Error(
|
||||
`Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLazyRouteAssetSizes(manifest, getSize, baseline) {
|
||||
const routeEntries = manifestEntries(manifest).filter(
|
||||
([, entry]) =>
|
||||
entry?.isDynamicEntry === true && typeof entry.src === "string",
|
||||
);
|
||||
if (routeEntries.length === 0) {
|
||||
throw new Error("Vite manifest must contain lazy route chunks");
|
||||
}
|
||||
|
||||
const routeSizes = Object.fromEntries(
|
||||
routeEntries
|
||||
.map(([entryName, entry]) => [
|
||||
entry.src,
|
||||
getSize(assetFile(entry, entryName)),
|
||||
])
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
if (!baseline) return routeSizes;
|
||||
|
||||
for (const [route, size] of Object.entries(routeSizes)) {
|
||||
if (!Number.isSafeInteger(baseline[route])) {
|
||||
throw new Error(`Missing baseline for lazy route chunk '${route}'`);
|
||||
}
|
||||
if (size > baseline[route]) {
|
||||
throw new Error(
|
||||
`Lazy route chunk '${route}' is ${size} bytes; baseline is ${baseline[route]} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return routeSizes;
|
||||
}
|
||||
|
||||
const distributionDirectory = resolve("dist");
|
||||
const manifestPath = resolve(distributionDirectory, "bundle-manifest.json");
|
||||
const baselinePath = resolve("scripts/lazy-route-bundle-baseline.json");
|
||||
|
||||
async function loadJson(path) {
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
}
|
||||
|
||||
function getAssetSize(file) {
|
||||
return statSync(resolve(distributionDirectory, file)).size;
|
||||
}
|
||||
|
||||
async function run(mode) {
|
||||
const manifest = await loadJson(manifestPath);
|
||||
const initialBytes = measureAssets(
|
||||
getInitialAssetFiles(manifest),
|
||||
getAssetSize,
|
||||
);
|
||||
assertBundleBudget(initialBytes);
|
||||
const baseline =
|
||||
mode === "baseline" ? undefined : await loadJson(baselinePath);
|
||||
const lazyRouteSizes = getLazyRouteAssetSizes(
|
||||
manifest,
|
||||
getAssetSize,
|
||||
baseline,
|
||||
);
|
||||
|
||||
if (mode === "baseline") {
|
||||
await writeFile(
|
||||
baselinePath,
|
||||
`${JSON.stringify(lazyRouteSizes, null, "\t")}\n`,
|
||||
);
|
||||
}
|
||||
console.log(`Initial JavaScript payload: ${initialBytes} bytes`);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
const mode = process.argv[2] ?? "check";
|
||||
if (mode !== "check" && mode !== "baseline") {
|
||||
throw new Error("Usage: node scripts/bundle-budget.mjs [check|baseline]");
|
||||
}
|
||||
await run(mode);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assertBundleBudget,
|
||||
getInitialAssetFiles,
|
||||
getLazyRouteAssetSizes,
|
||||
INITIAL_BUNDLE_BUDGET_BYTES,
|
||||
measureAssets,
|
||||
} from "./bundle-budget.mjs";
|
||||
|
||||
const manifest = {
|
||||
"index.html": {
|
||||
file: "assets/entry.js",
|
||||
isEntry: true,
|
||||
imports: ["_shared"],
|
||||
dynamicImports: ["src/pages/account/ui/AccountPage.tsx"],
|
||||
},
|
||||
_shared: { file: "assets/shared.js", imports: ["_runtime"] },
|
||||
_runtime: { file: "assets/runtime.js" },
|
||||
"src/pages/account/ui/AccountPage.tsx": {
|
||||
file: "assets/account.js",
|
||||
isDynamicEntry: true,
|
||||
src: "src/pages/account/ui/AccountPage.tsx",
|
||||
},
|
||||
"src/app/routes/ProtectedLayout.tsx": {
|
||||
file: "assets/protected-layout.js",
|
||||
isDynamicEntry: true,
|
||||
src: "src/app/routes/ProtectedLayout.tsx",
|
||||
},
|
||||
};
|
||||
|
||||
const sizes = new Map([
|
||||
["assets/entry.js", 200 * 1024],
|
||||
["assets/shared.js", 175 * 1024],
|
||||
["assets/runtime.js", 100 * 1024],
|
||||
["assets/account.js", 250 * 1024],
|
||||
["assets/protected-layout.js", 50 * 1024],
|
||||
]);
|
||||
|
||||
test("measures only the eager import graph of the entry bundle", () => {
|
||||
const assets = getInitialAssetFiles(manifest);
|
||||
assert.deepEqual(assets, [
|
||||
"assets/entry.js",
|
||||
"assets/shared.js",
|
||||
"assets/runtime.js",
|
||||
]);
|
||||
assert.equal(
|
||||
measureAssets(assets, (asset) => sizes.get(asset)),
|
||||
475 * 1024,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an initial bundle above the configured budget", () => {
|
||||
assert.throws(
|
||||
() => assertBundleBudget(INITIAL_BUNDLE_BUDGET_BYTES + 1),
|
||||
/JavaScript payload/,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects malformed manifests and lazy-route size regressions", () => {
|
||||
assert.throws(() => getInitialAssetFiles({}), /entry/);
|
||||
assert.deepEqual(
|
||||
getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset)),
|
||||
{
|
||||
"src/app/routes/ProtectedLayout.tsx": 50 * 1024,
|
||||
"src/pages/account/ui/AccountPage.tsx": 250 * 1024,
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), {
|
||||
"src/app/routes/ProtectedLayout.tsx": 50 * 1024,
|
||||
"src/pages/account/ui/AccountPage.tsx": 249 * 1024,
|
||||
}),
|
||||
/route chunk/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
getLazyRouteAssetSizes(manifest, (asset) => sizes.get(asset), {
|
||||
"src/pages/account/ui/AccountPage.tsx": 250 * 1024,
|
||||
}),
|
||||
/Missing baseline/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptsDirectory = dirname(fileURLToPath(import.meta.url));
|
||||
const projectDirectory = dirname(scriptsDirectory);
|
||||
const dependencyCruiser = join(
|
||||
projectDirectory,
|
||||
"node_modules/dependency-cruiser/bin/dependency-cruise.mjs",
|
||||
);
|
||||
const dependencyConfig = join(projectDirectory, "dependency-cruiser.cjs");
|
||||
|
||||
async function withFixture(files, run) {
|
||||
const fixtureDirectory = await mkdtemp(join(tmpdir(), "wealth-ledger-deps-"));
|
||||
try {
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([relativePath, contents]) => {
|
||||
const filePath = join(fixtureDirectory, relativePath);
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, contents);
|
||||
}),
|
||||
);
|
||||
return await run(fixtureDirectory);
|
||||
} finally {
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function cruise(fixtureDirectory) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
dependencyCruiser,
|
||||
"src",
|
||||
"--config",
|
||||
dependencyConfig,
|
||||
"--output-type",
|
||||
"err-long",
|
||||
],
|
||||
{ cwd: fixtureDirectory, encoding: "utf8" },
|
||||
);
|
||||
}
|
||||
|
||||
function output(result) {
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
|
||||
test("rejects a source import cycle", async () => {
|
||||
await withFixture(
|
||||
{
|
||||
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
|
||||
"src/shared/i18n/index.ts": 'export { value } from "./zod";\n',
|
||||
"src/shared/i18n/zod.ts": 'export { value } from ".";\n',
|
||||
},
|
||||
(fixtureDirectory) => {
|
||||
const result = cruise(fixtureDirectory);
|
||||
assert.notEqual(result.status, 0, output(result));
|
||||
assert.match(output(result), /no-circular/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a deep import into another slice", async () => {
|
||||
await withFixture(
|
||||
{
|
||||
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
|
||||
"src/features/auth/ui/Form.ts": 'import "../../../shared/i18n/zod";\n',
|
||||
"src/shared/i18n/index.ts": "export {};\n",
|
||||
"src/shared/i18n/zod.ts": "export {};\n",
|
||||
},
|
||||
(fixtureDirectory) => {
|
||||
const result = cruise(fixtureDirectory);
|
||||
assert.notEqual(result.status, 0, output(result));
|
||||
assert.match(output(result), /slices-must-use-public-api/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an index.tsx import from another slice", async () => {
|
||||
await withFixture(
|
||||
{
|
||||
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
|
||||
"src/features/auth/ui/Form.ts": 'import "../../../shared/i18n";\n',
|
||||
"src/shared/i18n/index.tsx": "export {};\n",
|
||||
},
|
||||
(fixtureDirectory) => {
|
||||
const result = cruise(fixtureDirectory);
|
||||
assert.notEqual(result.status, 0, output(result));
|
||||
assert.match(output(result), /slices-must-use-public-api/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("allows imports within a slice and through another slice public API", async () => {
|
||||
await withFixture(
|
||||
{
|
||||
"tsconfig.app.json": JSON.stringify({ compilerOptions: {} }),
|
||||
"src/features/auth/model/local.ts": "export const local = true;\n",
|
||||
"src/features/auth/ui/Form.ts": [
|
||||
'import { local } from "../model/local";\n',
|
||||
'import { value } from "../../../shared/i18n";\n',
|
||||
"void local;\n",
|
||||
"void value;\n",
|
||||
].join(""),
|
||||
"src/shared/i18n/index.ts": 'export { value } from "./zod";\n',
|
||||
"src/shared/i18n/zod.ts": "export const value = true;\n",
|
||||
},
|
||||
(fixtureDirectory) => {
|
||||
const result = cruise(fixtureDirectory);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"src/app/routes/ProtectedLayout.tsx": 3137,
|
||||
"src/pages/account-events/index.ts": 4468,
|
||||
"src/pages/account-operations/index.ts": 13861,
|
||||
"src/pages/account/index.ts": 17018,
|
||||
"src/pages/accounts/index.ts": 6174,
|
||||
"src/pages/login/index.ts": 669,
|
||||
"src/pages/not-found/index.ts": 850,
|
||||
"src/pages/register/index.ts": 675,
|
||||
"src/pages/settings/index.ts": 38876
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
AccountsPageError,
|
||||
AccountsPageLoading,
|
||||
} from "@/pages/accounts";
|
||||
import { server } from "@/test/server";
|
||||
import { theme } from "../theme";
|
||||
import { AppRoutes } from "./AppRoutes";
|
||||
|
||||
@@ -89,6 +91,30 @@ describe("AppRoutes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
|
||||
server.use(
|
||||
http.post("*/api/v1/auth/refresh", () =>
|
||||
HttpResponse.json({
|
||||
accessToken: "test-token",
|
||||
user: { id: "user-1", email: "user@example.com" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
renderRoutes("/accounts");
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Нет инвестиционных счетов",
|
||||
}),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "WealthLedger" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["loading", AccountsPageLoading, "status"],
|
||||
["error", AccountsPageError, "alert"],
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
import { Loader } from "@mantine/core";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router";
|
||||
import { useAuth } from "@/features/auth";
|
||||
import { AccountPage } from "@/pages/account";
|
||||
import { AccountEventsPage } from "@/pages/account-events";
|
||||
import { AccountOperationsPage } from "@/pages/account-operations";
|
||||
import { AccountsPage } from "@/pages/accounts";
|
||||
import { LoginPage } from "@/pages/login";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
import { RegisterPage } from "@/pages/register";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { AppShell } from "@/shared/ui";
|
||||
import { AppBreadcrumbs } from "../ui/AppBreadcrumbs";
|
||||
|
||||
const AccountPage = lazy(async () => {
|
||||
const { AccountPage } = await import("@/pages/account");
|
||||
return { default: AccountPage };
|
||||
});
|
||||
const AccountEventsPage = lazy(async () => {
|
||||
const { AccountEventsPage } = await import("@/pages/account-events");
|
||||
return { default: AccountEventsPage };
|
||||
});
|
||||
const AccountOperationsPage = lazy(async () => {
|
||||
const { AccountOperationsPage } = await import("@/pages/account-operations");
|
||||
return { default: AccountOperationsPage };
|
||||
});
|
||||
const AccountsPage = lazy(async () => {
|
||||
const { AccountsPage } = await import("@/pages/accounts");
|
||||
return { default: AccountsPage };
|
||||
});
|
||||
const LoginPage = lazy(async () => {
|
||||
const { LoginPage } = await import("@/pages/login");
|
||||
return { default: LoginPage };
|
||||
});
|
||||
const NotFoundPage = lazy(async () => {
|
||||
const { NotFoundPage } = await import("@/pages/not-found");
|
||||
return { default: NotFoundPage };
|
||||
});
|
||||
const RegisterPage = lazy(async () => {
|
||||
const { RegisterPage } = await import("@/pages/register");
|
||||
return { default: RegisterPage };
|
||||
});
|
||||
const SettingsPage = lazy(async () => {
|
||||
const { SettingsPage } = await import("@/pages/settings");
|
||||
return { default: SettingsPage };
|
||||
});
|
||||
const ProtectedLayout = lazy(async () => {
|
||||
const { ProtectedLayout } = await import("./ProtectedLayout");
|
||||
return { default: ProtectedLayout };
|
||||
});
|
||||
|
||||
function LazyPage({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<Loader />}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
function Protected({ children }: { children: React.ReactNode }) {
|
||||
const { user, ready } = useAuth();
|
||||
@@ -40,7 +73,9 @@ export function AppRoutes() {
|
||||
path="/login"
|
||||
element={
|
||||
<Public>
|
||||
<LoginPage />
|
||||
<LazyPage>
|
||||
<LoginPage />
|
||||
</LazyPage>
|
||||
</Public>
|
||||
}
|
||||
/>
|
||||
@@ -48,30 +83,70 @@ export function AppRoutes() {
|
||||
path="/register"
|
||||
element={
|
||||
<Public>
|
||||
<RegisterPage />
|
||||
<LazyPage>
|
||||
<RegisterPage />
|
||||
</LazyPage>
|
||||
</Public>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
<Protected>
|
||||
<AppShell contentAboveOutlet={<AppBreadcrumbs />} />
|
||||
<LazyPage>
|
||||
<ProtectedLayout />
|
||||
</LazyPage>
|
||||
</Protected>
|
||||
}
|
||||
>
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/accounts/:accountId" element={<AccountPage />} />
|
||||
<Route
|
||||
path="/accounts"
|
||||
element={
|
||||
<LazyPage>
|
||||
<AccountsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId"
|
||||
element={
|
||||
<LazyPage>
|
||||
<AccountPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/events"
|
||||
element={<AccountEventsPage />}
|
||||
element={
|
||||
<LazyPage>
|
||||
<AccountEventsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/accounts/:accountId/operations"
|
||||
element={<AccountOperationsPage />}
|
||||
element={
|
||||
<LazyPage>
|
||||
<AccountOperationsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<LazyPage>
|
||||
<SettingsPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<LazyPage>
|
||||
<NotFoundPage />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { AppBreadcrumbs } from "@/app/ui";
|
||||
import { AppShell } from "@/shared/ui";
|
||||
|
||||
export function ProtectedLayout() {
|
||||
return <AppShell contentAboveOutlet={<AppBreadcrumbs />} />;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { AppRoutes } from "./AppRoutes";
|
||||
export { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { AppRoutes } from "@/app/routes";
|
||||
import { RouteErrorBoundary } from "@/app/routes/RouteErrorBoundary";
|
||||
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
|
||||
import { theme } from "@/app/theme";
|
||||
import { AuthProvider } from "@/features/auth";
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AppBreadcrumbs } from "./AppBreadcrumbs";
|
||||
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
import { validationMessage } from "../../../shared/i18n";
|
||||
import { validationMessage } from "@/shared/i18n";
|
||||
|
||||
function isEmailCharacterInvalid(character: string): boolean {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import { ru } from "./locales/ru";
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: { ru },
|
||||
lng: "ru",
|
||||
fallbackLng: "ru",
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export { i18n };
|
||||
@@ -1,14 +1 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import { ru } from "./locales/ru";
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: { ru },
|
||||
lng: "ru",
|
||||
fallbackLng: "ru",
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export { validationMessage } from "./zod";
|
||||
export { i18n };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ParseKeys } from "i18next";
|
||||
|
||||
import { i18n } from ".";
|
||||
import { i18n } from "./i18n";
|
||||
|
||||
export function validationMessage(
|
||||
key: ParseKeys,
|
||||
|
||||
@@ -7,4 +7,5 @@ import { defineConfig } from "vite";
|
||||
export default defineConfig({
|
||||
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
|
||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||
build: { manifest: "bundle-manifest.json" },
|
||||
});
|
||||
|
||||
+15
-4
@@ -1,14 +1,25 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
exclude: [...configDefaults.exclude, "scripts/**/*.test.mjs"],
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
coverage: { reporter: ["text", "html"] },
|
||||
coverage: {
|
||||
all: true,
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/test/**", "src/**/*.test.{ts,tsx}"],
|
||||
reporter: ["text", "html"],
|
||||
thresholds: {
|
||||
statements: 82,
|
||||
branches: 75,
|
||||
functions: 75,
|
||||
lines: 83,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
Reference in New Issue
Block a user