codex/fsd-frontend-refactor #31

Merged
ksv741 merged 6 commits from codex/fsd-frontend-refactor into main 2026-06-20 23:29:09 +03:00
6 changed files with 102 additions and 46 deletions
Showing only changes of commit 2744608d06 - Show all commits

View File

@ -7,7 +7,7 @@ module.exports = {
sourceType: 'module', sourceType: 'module',
ecmaFeatures: { jsx: true }, ecmaFeatures: { jsx: true },
}, },
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import'], plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks', 'import', '@conarti/feature-sliced'],
extends: [ extends: [
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react/recommended', 'plugin:react/recommended',
@ -33,7 +33,18 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
// FSD layer boundaries // FSD layer boundaries (from @conarti/eslint-plugin-feature-sliced)
// layers-slices: catches cross-layer violations (e.g., shared→entities)
'@conarti/feature-sliced/layers-slices': ['error', {
// allow test files and test utilities to import from any layer for mocking
ignoreInFilesPatterns: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/test/**'],
}],
// absolute-relative: false positives with @/ alias convention — disabled
'@conarti/feature-sliced/absolute-relative': 'off',
// public-api: too strict for app/ and test internals — disabled
'@conarti/feature-sliced/public-api': 'off',
// FSD layer boundaries (from import/no-restricted-paths)
// NOTE: `from` = what's being imported, `target` = the file doing the import // NOTE: `from` = what's being imported, `target` = the file doing the import
'import/no-restricted-paths': [ 'import/no-restricted-paths': [
'error', 'error',

View File

@ -21,6 +21,7 @@
"react-router-dom": "^6.20.0" "react-router-dom": "^6.20.0"
}, },
"devDependencies": { "devDependencies": {
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
BROKER_OPERATION_TYPE_OPTIONS, BROKER_OPERATION_TYPE_OPTIONS,
@ -7,43 +7,30 @@ import {
} from '@/entities/broker-operation'; } from '@/entities/broker-operation';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table';
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
export function BrokerOperationsPage() { export function BrokerOperationsPage() {
const { accountId } = useBrokerAccountContext(); const { accountId } = useBrokerAccountContext();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const urlType = searchParams.get('type'); const urlType = searchParams.get('type');
const selectedType = isBrokerOperationType(urlType) ? urlType : ''; const selectedType = isBrokerOperationType(urlType) ? urlType : '';
const [cursor, setCursor] = useState<string | undefined>(undefined); const pagination = useCursorPagination();
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const operations = useBrokerOperations(accountId, { const operations = useBrokerOperations(accountId, {
limit: 10, limit: 10,
cursor, cursor: pagination.cursor,
operationTypes: selectedType || undefined, operationTypes: selectedType || undefined,
}); });
useEffect(() => { useEffect(() => {
setCursor(undefined); pagination.reset();
setCursorStack([]); }, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedType]);
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) { function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
const nextType = event.target.value; const nextType = event.target.value;
setSearchParams(nextType ? { type: nextType } : {}, { replace: true }); setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
} }
function handleNext() {
const nextCursor = operations.data?.nextCursor;
if (!nextCursor || !operations.data?.hasNext) return;
setCursorStack((previous) => [...previous, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
setCursor(cursorStack[cursorStack.length - 1]);
setCursorStack((previous) => previous.slice(0, -1));
}
const history = operations.error ? ( const history = operations.error ? (
<p role="alert">Не удалось загрузить историю операций</p> <p role="alert">Не удалось загрузить историю операций</p>
) : ( ) : (
@ -56,11 +43,11 @@ export function BrokerOperationsPage() {
isFetching={operations.isFetching} isFetching={operations.isFetching}
page={operations.data} page={operations.data}
pagination={{ pagination={{
pageNumber: cursorStack.length + 1, pageNumber: pagination.pageNumber,
canGoBack: cursorStack.length > 0, canGoBack: pagination.pageNumber > 1,
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
onPrevious: handlePrevious, onPrevious: pagination.handlePrevious,
onNext: handleNext, onNext: () => pagination.handleNext(operations.data?.nextCursor),
}} }}
/> />
); );

View File

@ -1,7 +1,7 @@
import { useState } from 'react';
import { useBrokerPositions } from '@/entities/broker-position'; import { useBrokerPositions } from '@/entities/broker-position';
import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout';
import { BrokerPositionTable } from '@/widgets/broker-positions-table'; import { BrokerPositionTable } from '@/widgets/broker-positions-table';
import { useCursorPagination } from '@/shared/lib/useCursorPagination';
type BrokerPositionsPageProps = { type BrokerPositionsPageProps = {
type: 'share' | 'bond'; type: 'share' | 'bond';
@ -10,22 +10,8 @@ type BrokerPositionsPageProps = {
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
const { accountId } = useBrokerAccountContext(); const { accountId } = useBrokerAccountContext();
const [cursor, setCursor] = useState<string | undefined>(undefined); const pagination = useCursorPagination();
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]); const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor });
const positions = useBrokerPositions(accountId, { type, limit: 10, cursor });
function handleNext() {
const nextCursor = positions.data?.nextCursor;
if (!nextCursor || !positions.data?.hasNext) return;
setCursorStack((previous) => [...previous, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
setCursor(cursorStack[cursorStack.length - 1]);
setCursorStack((previous) => previous.slice(0, -1));
}
if (positions.error) { if (positions.error) {
return ( return (
@ -47,9 +33,9 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
isLoading={positions.isLoading} isLoading={positions.isLoading}
isFetching={positions.isFetching} isFetching={positions.isFetching}
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'} emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
pageNumber={cursorStack.length + 1} pageNumber={pagination.pageNumber}
onNext={handleNext} onNext={() => pagination.handleNext(positions.data?.nextCursor)}
onPrevious={handlePrevious} onPrevious={pagination.handlePrevious}
/> />
); );
} }

View File

@ -0,0 +1,40 @@
import { useState, useCallback } from 'react';
export function useCursorPagination() {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const handleNext = useCallback(
(nextCursor: string | null | undefined) => {
if (!nextCursor) return;
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[cursor],
);
const handlePrevious = useCallback(() => {
setCursorStack((prev) => {
if (prev.length === 0) return prev;
const lastCursor = prev[prev.length - 1];
const remaining = prev.slice(0, -1);
setCursor(lastCursor);
return remaining;
});
return undefined;
}, []);
const reset = useCallback(() => {
setCursor(undefined);
setCursorStack([]);
}, []);
return {
cursor,
pageNumber: cursorStack.length + 1,
handleNext,
handlePrevious,
reset,
};
}

31
package-lock.json generated
View File

@ -93,6 +93,7 @@
"react-router-dom": "^6.20.0" "react-router-dom": "^6.20.0"
}, },
"devDependencies": { "devDependencies": {
"@conarti/eslint-plugin-feature-sliced": "^1.0.5",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@ -3416,6 +3417,36 @@
"node": ">=0.1.90" "node": ">=0.1.90"
} }
}, },
"node_modules/@conarti/eslint-plugin-feature-sliced": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@conarti/eslint-plugin-feature-sliced/-/eslint-plugin-feature-sliced-1.0.5.tgz",
"integrity": "sha512-Gx90Zupi8nv6cCP3O6cYWE9GT4RIw0D9JBHWeUwXEv7qmjCd3dXSqDEmQchN7k41hhCPupZvMevH4JrSORcVjQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"picomatch": "^2.3.1"
},
"engines": {
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7",
"eslint-plugin-import": ">=2.26"
}
},
"node_modules/@conarti/eslint-plugin-feature-sliced/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@csstools/cascade-layer-name-parser": { "node_modules/@csstools/cascade-layer-name-parser": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz",