diff --git a/workspaces/frontend/.eslintrc.js b/workspaces/frontend/.eslintrc.js index 53455a9c..09f4cb08 100644 --- a/workspaces/frontend/.eslintrc.js +++ b/workspaces/frontend/.eslintrc.js @@ -1,5 +1,3 @@ -const noReactHookNamespace = require('./eslint-local-rules/no-react-hook-namespace'); - module.exports = { parser: '@typescript-eslint/parser', env: { @@ -13,7 +11,7 @@ module.exports = { js: true, useJSXTextNode: true, project: './tsconfig.json', - tsconfigRootDir: '.', + tsconfigRootDir: __dirname, }, // includes the typescript specific rules found here: https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#supported-rules plugins: [ @@ -24,6 +22,7 @@ module.exports = { 'no-only-tests', 'no-relative-import-paths', 'prettier', + 'local-rules', ], extends: [ 'eslint:recommended', @@ -200,6 +199,17 @@ module.exports = { 'no-lone-blocks': 'error', 'no-lonely-if': 'error', 'no-promise-executor-return': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react-router', + message: 'Use react-router-dom instead.', + }, + ], + }, + ], 'no-restricted-globals': [ 'error', { @@ -221,7 +231,8 @@ module.exports = { 'symbol-description': 'error', yoda: 'error', 'func-names': 'warn', - 'no-react-hook-namespace': 'error', + 'local-rules/no-react-hook-namespace': 'error', + 'local-rules/no-raw-react-router-hook': 'error', }, overrides: [ { @@ -270,7 +281,20 @@ module.exports = { { files: ['**/*.{js,jsx,ts,tsx}'], rules: { - 'no-react-hook-namespace': 'error', + 'local-rules/no-react-hook-namespace': 'error', + 'local-rules/no-raw-react-router-hook': 'error', + }, + }, + { + files: ['.eslintrc.js'], + parserOptions: { + project: null, + }, + }, + { + files: ['eslint-local-rules/**/*.js'], + rules: { + '@typescript-eslint/no-require-imports': 'off', }, }, ], diff --git a/workspaces/frontend/eslint-local-rules/index.js b/workspaces/frontend/eslint-local-rules/index.js new file mode 100644 index 00000000..7643244e --- /dev/null +++ b/workspaces/frontend/eslint-local-rules/index.js @@ -0,0 +1,4 @@ +module.exports = { + 'no-react-hook-namespace': require('./no-react-hook-namespace'), + 'no-raw-react-router-hook': require('./no-raw-react-router-hook'), +}; diff --git a/workspaces/frontend/eslint-local-rules/no-raw-react-router-hook.js b/workspaces/frontend/eslint-local-rules/no-raw-react-router-hook.js new file mode 100644 index 00000000..40b07448 --- /dev/null +++ b/workspaces/frontend/eslint-local-rules/no-raw-react-router-hook.js @@ -0,0 +1,46 @@ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Disallow use of raw react-router-dom hooks. Use typed wrappers instead.', + }, + messages: { + avoidRawHook: + 'Use "{{typedHook}}" from `~/app/routerHelper` instead of raw React Router hook "{{rawHook}}".', + }, + schema: [], + }, + + create(context) { + const forbiddenHooks = { + useNavigate: 'useTypedNavigate', + useParams: 'useTypedParams', + useSearchParams: 'useTypedSearchParams', + useLocation: 'useTypedLocation', + }; + + return { + ImportDeclaration(node) { + if (node.source.value !== 'react-router-dom') { + return; + } + + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ImportSpecifier' && + Object.prototype.hasOwnProperty.call(forbiddenHooks, specifier.imported.name) + ) { + context.report({ + node: specifier, + messageId: 'avoidRawHook', + data: { + rawHook: specifier.imported.name, + typedHook: forbiddenHooks[specifier.imported.name], + }, + }); + } + } + }, + }; + }, +}; diff --git a/workspaces/frontend/package-lock.json b/workspaces/frontend/package-lock.json index a3b9b84d..feb1e0eb 100644 --- a/workspaces/frontend/package-lock.json +++ b/workspaces/frontend/package-lock.json @@ -18,6 +18,7 @@ "@patternfly/react-tokens": "^6.2.0", "@types/js-yaml": "^4.0.9", "date-fns": "^4.1.0", + "eslint-plugin-local-rules": "^3.0.2", "js-yaml": "^4.1.0", "npm-run-all": "^4.1.5", "react": "^18", @@ -9966,6 +9967,11 @@ "license": "MIT", "optional": true }, + "node_modules/eslint-plugin-local-rules": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-local-rules/-/eslint-plugin-local-rules-3.0.2.tgz", + "integrity": "sha512-IWME7GIYHXogTkFsToLdBCQVJ0U4kbSuVyDT+nKoR4UgtnVrrVeNWuAZkdEu1nxkvi9nsPccGehEEF6dgA28IQ==" + }, "node_modules/eslint-plugin-no-only-tests": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz", diff --git a/workspaces/frontend/package.json b/workspaces/frontend/package.json index ca879d9e..8e08b930 100644 --- a/workspaces/frontend/package.json +++ b/workspaces/frontend/package.json @@ -24,8 +24,8 @@ "test:unit": "npm run test:jest -- --silent", "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "test:fix": "eslint --rulesdir eslint-local-rules --ext .js,.ts,.jsx,.tsx ./src --fix", - "test:lint": "eslint --rulesdir eslint-local-rules --max-warnings 0 --ext .js,.ts,.jsx,.tsx ./src", + "test:fix": "eslint --ext .js,.ts,.jsx,.tsx ./src --fix", + "test:lint": "eslint --max-warnings 0 --ext .js,.ts,.jsx,.tsx ./src", "cypress:open": "cypress open --project src/__tests__/cypress", "cypress:open:mock": "CY_MOCK=1 CY_WS_PORT=9002 npm run cypress:open -- ", "cypress:run": "cypress run -b chrome --project src/__tests__/cypress", @@ -108,6 +108,7 @@ "@patternfly/react-tokens": "^6.2.0", "@types/js-yaml": "^4.0.9", "date-fns": "^4.1.0", + "eslint-plugin-local-rules": "^3.0.2", "js-yaml": "^4.1.0", "npm-run-all": "^4.1.5", "react": "^18", diff --git a/workspaces/frontend/src/app/NavSidebar.tsx b/workspaces/frontend/src/app/NavSidebar.tsx index e9b7195b..f1deb892 100644 --- a/workspaces/frontend/src/app/NavSidebar.tsx +++ b/workspaces/frontend/src/app/NavSidebar.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { NavLink, useLocation } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import { Brand, Nav, @@ -9,11 +9,12 @@ import { PageSidebar, PageSidebarBody, } from '@patternfly/react-core'; +import { useTypedLocation } from '~/app/routerHelper'; import { useNavData, isNavDataGroup, NavDataHref, NavDataGroup } from './AppRoutes'; import { isMUITheme, LOGO_LIGHT } from './const'; const NavHref: React.FC<{ item: NavDataHref }> = ({ item }) => { - const location = useLocation(); + const location = useTypedLocation(); // With the redirect in place, we can now use a simple path comparison. const isActive = location.pathname === item.path; diff --git a/workspaces/frontend/src/app/hooks/useCurrentRouteKey.ts b/workspaces/frontend/src/app/hooks/useCurrentRouteKey.ts index 34077823..69da59cf 100644 --- a/workspaces/frontend/src/app/hooks/useCurrentRouteKey.ts +++ b/workspaces/frontend/src/app/hooks/useCurrentRouteKey.ts @@ -1,8 +1,9 @@ -import { useLocation, matchPath } from 'react-router-dom'; +import { matchPath } from 'react-router-dom'; import { AppRouteKey, AppRoutePaths } from '~/app/routes'; +import { useTypedLocation } from '~/app/routerHelper'; export function useCurrentRouteKey(): AppRouteKey | undefined { - const location = useLocation(); + const location = useTypedLocation(); const { pathname } = location; const matchEntries = Object.entries(AppRoutePaths) as [AppRouteKey, string][]; diff --git a/workspaces/frontend/src/app/routerHelper.ts b/workspaces/frontend/src/app/routerHelper.ts index 4b600c46..332b473d 100644 --- a/workspaces/frontend/src/app/routerHelper.ts +++ b/workspaces/frontend/src/app/routerHelper.ts @@ -1,3 +1,5 @@ +/* eslint-disable local-rules/no-raw-react-router-hook */ + import { generatePath, Location,