diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs index 7b78313..6d96ee8 100644 --- a/apps/frontend/.eslintrc.cjs +++ b/apps/frontend/.eslintrc.cjs @@ -7,7 +7,7 @@ module.exports = { sourceType: 'module', 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: [ 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', @@ -33,7 +33,18 @@ module.exports = { '@typescript-eslint/no-explicit-any': '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 'import/no-restricted-paths': [ 'error', diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b50d1ae..c8dd5fe 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -21,6 +21,7 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx index a71ade0..2a52d29 100644 --- a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { BROKER_OPERATION_TYPE_OPTIONS, @@ -7,43 +7,30 @@ import { } from '@/entities/broker-operation'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { BrokerOperationsTable } from '@/widgets/broker-operations-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; export function BrokerOperationsPage() { const { accountId } = useBrokerAccountContext(); const [searchParams, setSearchParams] = useSearchParams(); const urlType = searchParams.get('type'); const selectedType = isBrokerOperationType(urlType) ? urlType : ''; - const [cursor, setCursor] = useState(undefined); - const [cursorStack, setCursorStack] = useState>([]); + const pagination = useCursorPagination(); + const operations = useBrokerOperations(accountId, { limit: 10, - cursor, + cursor: pagination.cursor, operationTypes: selectedType || undefined, }); useEffect(() => { - setCursor(undefined); - setCursorStack([]); - }, [selectedType]); + pagination.reset(); + }, [selectedType]); // eslint-disable-line react-hooks/exhaustive-deps function handleTypeChange(event: React.ChangeEvent) { const nextType = event.target.value; 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 ? (

Не удалось загрузить историю операций

) : ( @@ -56,11 +43,11 @@ export function BrokerOperationsPage() { isFetching={operations.isFetching} page={operations.data} pagination={{ - pageNumber: cursorStack.length + 1, - canGoBack: cursorStack.length > 0, + pageNumber: pagination.pageNumber, + canGoBack: pagination.pageNumber > 1, canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor), - onPrevious: handlePrevious, - onNext: handleNext, + onPrevious: pagination.handlePrevious, + onNext: () => pagination.handleNext(operations.data?.nextCursor), }} /> ); diff --git a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx index 3ecc332..5586b0b 100644 --- a/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx +++ b/apps/frontend/src/pages/broker-positions/ui/BrokerPositionsPage.tsx @@ -1,7 +1,7 @@ -import { useState } from 'react'; import { useBrokerPositions } from '@/entities/broker-position'; import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; import { BrokerPositionTable } from '@/widgets/broker-positions-table'; +import { useCursorPagination } from '@/shared/lib/useCursorPagination'; type BrokerPositionsPageProps = { type: 'share' | 'bond'; @@ -10,22 +10,8 @@ type BrokerPositionsPageProps = { export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { const { accountId } = useBrokerAccountContext(); - const [cursor, setCursor] = useState(undefined); - const [cursorStack, setCursorStack] = useState>([]); - 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)); - } + const pagination = useCursorPagination(); + const positions = useBrokerPositions(accountId, { type, limit: 10, cursor: pagination.cursor }); if (positions.error) { return ( @@ -47,9 +33,9 @@ export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) { isLoading={positions.isLoading} isFetching={positions.isFetching} emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'} - pageNumber={cursorStack.length + 1} - onNext={handleNext} - onPrevious={handlePrevious} + pageNumber={pagination.pageNumber} + onNext={() => pagination.handleNext(positions.data?.nextCursor)} + onPrevious={pagination.handlePrevious} /> ); } diff --git a/apps/frontend/src/shared/lib/useCursorPagination.ts b/apps/frontend/src/shared/lib/useCursorPagination.ts new file mode 100644 index 0000000..6bf48b6 --- /dev/null +++ b/apps/frontend/src/shared/lib/useCursorPagination.ts @@ -0,0 +1,40 @@ +import { useState, useCallback } from 'react'; + +export function useCursorPagination() { + const [cursor, setCursor] = useState(undefined); + const [cursorStack, setCursorStack] = useState>([]); + + 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, + }; +} diff --git a/package-lock.json b/package-lock.json index 55bb494..47e86fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,6 +93,7 @@ "react-router-dom": "^6.20.0" }, "devDependencies": { + "@conarti/eslint-plugin-feature-sliced": "^1.0.5", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -3416,6 +3417,36 @@ "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": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz",