Create ESLint rule to discourage usage of navigate() and prefer Link (#5642)

### Description
Create ESLint rule to discourage usage of navigate() and prefer Link


### Refs
#5468 

### Demo

![Capture-2024-05-29-112852](https://github.com/twentyhq/twenty/assets/140154534/28378c09-86bb-49d3-9e9a-49aa1c07ad11)

![Capture-2024-05-29-112843](https://github.com/twentyhq/twenty/assets/140154534/2c05ea92-e19b-49ae-acb9-07f6ec9182ab)

Fixes #5468

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: v1b3m <vibenjamin6@gmail.com>
Co-authored-by: Matheus <matheus_benini@hotmail.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
gitstart-twenty 2024-06-04 11:04:57 -04:00 committed by GitHub
parent 234e062232
commit bb7d94a455
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 317 additions and 132 deletions

View File

@ -47,6 +47,7 @@ module.exports = {
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
'@nx/workspace-no-navigate-prefer-link': 'error',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react/jsx-key': 'off',

View File

@ -1,5 +1,4 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Key } from 'ts-key-enum';
import {
IconBaselineDensitySmall,
@ -27,6 +26,7 @@ import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenu
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemNavigate } from '@/ui/navigation/menu-item/components/MenuItemNavigate';
import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle';
@ -50,8 +50,6 @@ export const RecordIndexOptionsDropdownContent = ({
}: RecordIndexOptionsDropdownContentProps) => {
const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView();
const navigate = useNavigate();
const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID);
const [currentMenu, setCurrentMenu] = useState<
@ -68,13 +66,9 @@ export const RecordIndexOptionsDropdownContent = ({
objectNameSingular: objectNameSingular,
});
const handleEditClick = () => {
navigate(
getSettingsPagePath(SettingsPath.ObjectDetail, {
objectSlug: objectNamePlural,
}),
);
};
const settingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, {
objectSlug: objectNamePlural,
});
useScopedHotkeys(
[Key.Escape],
@ -194,13 +188,12 @@ export const RecordIndexOptionsDropdownContent = ({
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleEditClick}
LeftIcon={IconSettings}
text="Edit Fields"
/>
</DropdownMenuItemsContainer>
<UndecoratedLink to={settingsUrl}>
<DropdownMenuItemsContainer>
<MenuItem LeftIcon={IconSettings} text="Edit Fields" />
</DropdownMenuItemsContainer>
</UndecoratedLink>
</>
)}

View File

@ -1,4 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import styled from '@emotion/styled';
import { H2Title, IconCalendarEvent, IconMailCog } from 'twenty-ui';
@ -6,6 +6,7 @@ import { SettingsNavigationCard } from '@/settings/components/SettingsNavigation
import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath';
import { SettingsPath } from '@/types/SettingsPath';
import { Section } from '@/ui/layout/section/components/Section';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
const StyledCardsContainer = styled.div`
display: flex;
@ -14,8 +15,6 @@ const StyledCardsContainer = styled.div`
`;
export const SettingsAccountsSettingsSection = () => {
const navigate = useNavigate();
return (
<Section>
<H2Title
@ -23,24 +22,16 @@ export const SettingsAccountsSettingsSection = () => {
description="Configure your emails and calendar settings."
/>
<StyledCardsContainer>
<SettingsNavigationCard
Icon={IconMailCog}
title="Emails"
onClick={() =>
navigate(getSettingsPagePath(SettingsPath.AccountsEmails))
}
>
Set email visibility, manage your blocklist and more.
</SettingsNavigationCard>
<SettingsNavigationCard
Icon={IconCalendarEvent}
title="Calendar"
onClick={() =>
navigate(getSettingsPagePath(SettingsPath.AccountsCalendars))
}
>
Configure and customize your calendar preferences.
</SettingsNavigationCard>
<UndecoratedLink to={getSettingsPagePath(SettingsPath.AccountsEmails)}>
<SettingsNavigationCard Icon={IconMailCog} title="Emails">
Set email visibility, manage your blocklist and more.
</SettingsNavigationCard>
</UndecoratedLink>
<Link to={getSettingsPagePath(SettingsPath.AccountsCalendars)}>
<SettingsNavigationCard Icon={IconCalendarEvent} title="Calendar">
Configure and customize your calendar preferences.
</SettingsNavigationCard>
</Link>
</StyledCardsContainer>
</Section>
);

View File

@ -27,10 +27,6 @@ export const SettingsIntegrationDatabaseConnectionShowContainer = () => {
navigate(`${settingsIntegrationsPagePath}/${databaseKey}`);
};
const onEdit = () => {
navigate('./edit');
};
const settingsIntegrationsPagePath = getSettingsPagePath(
SettingsPath.Integrations,
);
@ -57,7 +53,6 @@ export const SettingsIntegrationDatabaseConnectionShowContainer = () => {
connectionId={connection.id}
connectionLabel={connection.label}
onRemove={deleteConnection}
onEdit={onEdit}
/>
</Section>
<Section>

View File

@ -7,6 +7,7 @@ import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type SettingsIntegrationDatabaseConnectionSummaryCardProps = {
@ -14,7 +15,6 @@ type SettingsIntegrationDatabaseConnectionSummaryCardProps = {
connectionId: string;
connectionLabel: string;
onRemove: () => void;
onEdit: () => void;
};
const StyledDatabaseLogoContainer = styled.div`
@ -34,7 +34,6 @@ export const SettingsIntegrationDatabaseConnectionSummaryCard = ({
connectionId,
connectionLabel,
onRemove,
onEdit,
}: SettingsIntegrationDatabaseConnectionSummaryCardProps) => {
const dropdownId =
'settings-integration-database-connection-summary-card-dropdown';
@ -69,11 +68,9 @@ export const SettingsIntegrationDatabaseConnectionSummaryCard = ({
text="Remove"
onClick={onRemove}
/>
<MenuItem
LeftIcon={IconPencil}
text="Edit"
onClick={onEdit}
/>
<UndecoratedLink to="./edit">
<MenuItem LeftIcon={IconPencil} text="Edit" />
</UndecoratedLink>
</DropdownMenuItemsContainer>
</DropdownMenu>
}

View File

@ -1,6 +1,5 @@
import { ComponentProps, ReactNode } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
@ -12,6 +11,7 @@ import {
} from 'twenty-ui';
import { IconButton } from '@/ui/input/button/components/IconButton';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerCollapseButton';
import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -103,7 +103,6 @@ export const PageHeader = ({
loading,
}: PageHeaderProps) => {
const isMobile = useIsMobile();
const navigate = useNavigate();
const theme = useTheme();
const isNavigationDrawerOpen = useRecoilValue(isNavigationDrawerOpenState);
@ -116,12 +115,13 @@ export const PageHeader = ({
</StyledTopBarButtonContainer>
)}
{hasBackButton && (
<IconButton
Icon={IconChevronLeft}
size="small"
onClick={() => navigate(-1)}
variant="tertiary"
/>
<UndecoratedLink to={-1}>
<IconButton
Icon={IconChevronLeft}
size="small"
variant="tertiary"
/>
</UndecoratedLink>
)}
{loading ? (
<StyledSkeletonLoader />

View File

@ -0,0 +1,25 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled from '@emotion/styled';
const StyledUndecoratedLink = styled(Link)`
text-decoration: none;
`;
type UndecoratedLinkProps = {
to: string | number;
children: React.ReactNode;
replace?: boolean;
};
export const UndecoratedLink = ({
children,
to,
replace = false,
}: UndecoratedLinkProps) => {
return (
<StyledUndecoratedLink to={to as string} replace={replace}>
{children}
</StyledUndecoratedLink>
);
};

View File

@ -0,0 +1,32 @@
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
const meta: Meta<typeof UndecoratedLink> = {
title: 'UI/navigation/link/UndecoratedLink',
component: UndecoratedLink,
decorators: [ComponentWithRouterDecorator],
};
export default meta;
type Story = StoryObj<typeof UndecoratedLink>;
export const Default: Story = {
args: {
children: 'Go Home',
to: '/home',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const link = canvas.getByText('Go Home');
await userEvent.click(link);
const href = link.getAttribute('href');
expect(href).toBe('/home');
},
};

View File

@ -1,9 +1,9 @@
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconChevronLeft } from 'twenty-ui';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
type NavigationDrawerBackButtonProps = {
@ -36,22 +36,19 @@ export const NavigationDrawerBackButton = ({
title,
}: NavigationDrawerBackButtonProps) => {
const theme = useTheme();
const navigate = useNavigate();
const navigationMemorizedUrl = useRecoilValue(navigationMemorizedUrlState);
return (
<StyledContainer>
<StyledIconAndButtonContainer
onClick={() => {
navigate(navigationMemorizedUrl, { replace: true });
}}
>
<IconChevronLeft
size={theme.icon.size.md}
stroke={theme.icon.stroke.lg}
/>
<span>{title}</span>
</StyledIconAndButtonContainer>
<UndecoratedLink to={navigationMemorizedUrl} replace>
<StyledIconAndButtonContainer>
<IconChevronLeft
size={theme.icon.size.md}
stroke={theme.icon.stroke.lg}
/>
<span>{title}</span>
</StyledIconAndButtonContainer>
</UndecoratedLink>
</StyledContainer>
);
};

View File

@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import { AppPath } from '@/types/AppPath';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { useAuthorizeAppMutation } from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
@ -115,12 +116,9 @@ export const Authorize = () => {
</StyledAppsContainer>
<StyledText>{app?.name} wants to access your account</StyledText>
<StyledButtonContainer>
<MainButton
title="Cancel"
variant="secondary"
onClick={() => navigate(AppPath.Index)}
fullWidth
/>
<UndecoratedLink to={AppPath.Index}>
<MainButton title="Cancel" variant="secondary" fullWidth />
</UndecoratedLink>
<MainButton title="Authorize" onClick={handleAuthorize} fullWidth />
</StyledButtonContainer>
</StyledCardWrapper>

View File

@ -1,5 +1,4 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck, RGBA } from 'twenty-ui';
@ -8,6 +7,7 @@ import { SubTitle } from '@/auth/components/SubTitle';
import { Title } from '@/auth/components/Title';
import { AppPath } from '@/types/AppPath';
import { MainButton } from '@/ui/input/button/components/MainButton';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { AnimatedEaseIn } from '@/ui/utilities/animation/components/AnimatedEaseIn';
const StyledCheckContainer = styled.div`
@ -28,11 +28,7 @@ const StyledButtonContainer = styled.div`
`;
export const PaymentSuccess = () => {
const navigate = useNavigate();
const theme = useTheme();
const handleButtonClick = () => {
navigate(AppPath.CreateWorkspace);
};
const color =
theme.name === 'light' ? theme.grayScale.gray90 : theme.grayScale.gray10;
return (
@ -45,7 +41,9 @@ export const PaymentSuccess = () => {
<Title>All set!</Title>
<SubTitle>Your account has been activated.</SubTitle>
<StyledButtonContainer>
<MainButton title="Start" onClick={handleButtonClick} width={200} />
<UndecoratedLink to={AppPath.CreateWorkspace}>
<MainButton title="Start" width={200} />
</UndecoratedLink>
</StyledButtonContainer>
</>
);

View File

@ -1,4 +1,3 @@
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { SignInBackgroundMockPage } from '@/sign-in-background-mock/components/SignInBackgroundMockPage';
@ -11,6 +10,7 @@ import {
AnimatedPlaceholderErrorSubTitle,
AnimatedPlaceholderErrorTitle,
} from '@/ui/layout/animated-placeholder/components/ErrorPlaceholderStyled';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
const StyledBackDrop = styled.div`
@ -33,8 +33,6 @@ const StyledButtonContainer = styled.div`
`;
export const NotFound = () => {
const navigate = useNavigate();
return (
<>
<PageTitle title="Page Not Found | Twenty" />
@ -51,11 +49,9 @@ export const NotFound = () => {
</AnimatedPlaceholderErrorSubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<StyledButtonContainer>
<MainButton
title="Back to content"
fullWidth
onClick={() => navigate(AppPath.Index)}
/>
<UndecoratedLink to={AppPath.Index}>
<MainButton title="Back to content" fullWidth />
</UndecoratedLink>
</StyledButtonContainer>
</AnimatedPlaceholderErrorContainer>
</StyledBackDrop>

View File

@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from '@emotion/styled';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
@ -25,6 +24,7 @@ import { Button } from '@/ui/input/button/components/Button';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
import {
useBillingPortalSessionQuery,
useUpdateBillingSubscriptionMutation,
@ -66,7 +66,6 @@ const SWITCH_INFOS = {
};
export const SettingsBilling = () => {
const navigate = useNavigate();
const { enqueueSnackBar } = useSnackBar();
const onboardingStatus = useOnboardingStatus();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@ -140,10 +139,6 @@ export const SettingsBilling = () => {
}
};
const redirectToSubscribePage = () => {
navigate(AppPath.PlanRequired);
};
return (
<SubMenuTopBarContainer Icon={IconCurrencyDollar} title="Billing">
<SettingsPageContainer>
@ -158,20 +153,22 @@ export const SettingsBilling = () => {
/>
)}
{displaySubscriptionCanceledInfo && (
<Info
text={'Subscription canceled. Please start a new one'}
buttonTitle={'Subscribe'}
accent={'danger'}
onClick={redirectToSubscribePage}
/>
<UndecoratedLink to={AppPath.PlanRequired}>
<Info
text={'Subscription canceled. Please start a new one'}
buttonTitle={'Subscribe'}
accent={'danger'}
/>
</UndecoratedLink>
)}
{displaySubscribeInfo ? (
<Info
text={'Your workspace does not have an active subscription'}
buttonTitle={'Subscribe'}
accent={'danger'}
onClick={redirectToSubscribePage}
/>
<UndecoratedLink to={AppPath.PlanRequired}>
<Info
text={'Your workspace does not have an active subscription'}
buttonTitle={'Subscribe'}
accent={'danger'}
/>
</UndecoratedLink>
) : (
<>
<Section>

View File

@ -31,6 +31,7 @@ import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
const StyledDiv = styled.div`
display: flex;
@ -214,19 +215,20 @@ export const SettingsObjectDetail = () => {
</Table>
{shouldDisplayAddFieldButton && (
<StyledDiv>
<Button
Icon={IconPlus}
title="Add Field"
size="small"
variant="secondary"
onClick={() =>
navigate(
deactivatedMetadataFields.length
? './new-field/step-1'
: './new-field/step-2',
)
<UndecoratedLink
to={
deactivatedMetadataFields.length
? './new-field/step-1'
: './new-field/step-2'
}
/>
>
<Button
Icon={IconPlus}
title="Add Field"
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledDiv>
)}
</Section>

View File

@ -1,4 +1,3 @@
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
@ -29,6 +28,7 @@ import { Section } from '@/ui/layout/section/components/Section';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink';
const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
@ -40,7 +40,6 @@ const StyledH1Title = styled(H1Title)`
export const SettingsObjects = () => {
const theme = useTheme();
const navigate = useNavigate();
const { activeObjectMetadataItems, inactiveObjectMetadataItems } =
useFilteredObjectMetadataItems();
@ -52,15 +51,14 @@ export const SettingsObjects = () => {
<SettingsPageContainer>
<SettingsHeaderContainer>
<StyledH1Title title="Objects" />
<Button
Icon={IconPlus}
title="Add object"
accent="blue"
size="small"
onClick={() =>
navigate(getSettingsPagePath(SettingsPath.NewObject))
}
/>
<UndecoratedLink to={getSettingsPagePath(SettingsPath.NewObject)}>
<Button
Icon={IconPlus}
title="Add object"
accent="blue"
size="small"
/>
</UndecoratedLink>
</SettingsHeaderContainer>
<div>
<SettingsObjectCoverImage />

View File

@ -22,6 +22,10 @@ import {
rule as noHardcodedColors,
RULE_NAME as noHardcodedColorsName,
} from './rules/no-hardcoded-colors';
import {
rule as noNavigatePreferLink,
RULE_NAME as noNavigatePreferLinkName,
} from './rules/no-navigate-prefer-link';
import {
rule as noStateUseref,
RULE_NAME as noStateUserefName,
@ -83,5 +87,6 @@ module.exports = {
[maxConstsPerFileName]: maxConstsPerFile,
[useRecoilCallbackHasDependencyArrayName]:
useRecoilCallbackHasDependencyArray,
[noNavigatePreferLinkName]: noNavigatePreferLink,
},
};

View File

@ -0,0 +1,59 @@
import { TSESLint } from '@typescript-eslint/utils';
import { rule, RULE_NAME } from './no-navigate-prefer-link';
const ruleTester = new TSESLint.RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
});
ruleTester.run(RULE_NAME, rule, {
valid: [
{
code: 'if(someVar) { navigate("/"); }',
},
{
code: '<Link to="/"><Button>Click me</Button></Link>',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
{
code: '<Button onClick={() =>{ navigate("/"); doSomething(); }} />',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
],
invalid: [
{
code: '<Button onClick={() => navigate("/")} />',
errors: [
{
messageId: 'preferLink',
},
],
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
{
code: '<Button onClick={() => { navigate("/");} } />',
errors: [
{
messageId: 'preferLink',
},
],
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
],
});

View File

@ -0,0 +1,101 @@
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
// NOTE: The rule will be available in ESLint configs as "@nx/workspace-no-navigate-prefer-link"
export const RULE_NAME = 'no-navigate-prefer-link';
export const rule = ESLintUtils.RuleCreator(() => __filename)({
name: RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description:
'Discourage usage of navigate() where a simple <Link> component would suffice.',
},
messages: {
preferLink: 'Use <Link> instead of navigate() for pure navigation.',
},
schema: [],
},
defaultOptions: [],
create: (context) => {
const functionMap: Record<string, TSESTree.ArrowFunctionExpression> = {};
const checkFunctionBodyHasSingleNavigateCall = (
func: TSESTree.ArrowFunctionExpression,
) => {
// Check for simple arrow function with single navigate call
if (
func.body.type === 'CallExpression' &&
func.body.callee.type === 'Identifier' &&
func.body.callee.name === 'navigate'
) {
return true;
}
// Check for block arrow function with single navigate call
if (
func.body.type === 'BlockStatement' &&
func.body.body.length === 1 &&
func.body.body[0].type === 'ExpressionStatement' &&
func.body.body[0].expression.type === 'CallExpression' &&
func.body.body[0].expression.callee.type === 'Identifier' &&
func.body.body[0].expression.callee.name === 'navigate'
) {
return true;
}
return false;
};
return {
VariableDeclarator: (node) => {
// Check for function declaration on onClick
if (
node.init &&
node.init.type === 'ArrowFunctionExpression' &&
node.id.type === 'Identifier'
) {
const func = node.init;
functionMap[node.id.name] = func;
if (checkFunctionBodyHasSingleNavigateCall(func)) {
context.report({
node: func,
messageId: 'preferLink',
});
}
}
},
JSXAttribute: (node) => {
// Check for navigate call directly on onClick
if (
node.name.name === 'onClick' &&
node.value.type === 'JSXExpressionContainer'
) {
const expression = node.value.expression;
if (
expression.type === 'ArrowFunctionExpression' &&
checkFunctionBodyHasSingleNavigateCall(expression)
) {
context.report({
node: expression,
messageId: 'preferLink',
});
} else if (
expression.type === 'Identifier' &&
functionMap[expression.name]
) {
const func = functionMap[expression.name];
if (checkFunctionBodyHasSingleNavigateCall(func)) {
context.report({
node: expression,
messageId: 'preferLink',
});
}
}
}
},
};
},
});