chore: add @conarti/eslint-plugin-feature-sliced for FSD layer enforcement
This commit is contained in:
parent
ad196164ee
commit
2744608d06
@ -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',
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
apps/frontend/src/shared/lib/useCursorPagination.ts
Normal file
40
apps/frontend/src/shared/lib/useCursorPagination.ts
Normal 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
31
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user