Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ module.exports = {
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['*/i18n', '**/lib/i18n'],
message:
'Do not import the i18n singleton. Use `const { t } = useTranslation()`, or accept `t: TFunction` in helpers outside a component. Only getServerSideProps, where hooks cannot run, and tests are exempt.',
},
],
},
],
'no-restricted-syntax': [
'error',
{
Expand Down Expand Up @@ -135,5 +147,25 @@ module.exports = {
'jsx-a11y/no-static-element-interactions': 'off',
},
},
// These two blocks lift every no-restricted-imports pattern, not just the
// i18n one. A second restriction added above will need re-stating here.
{
files: ['pages/**'],
rules: {
'no-restricted-imports': 'off',
},
},
{
files: [
'*.test.ts',
'*.test.tsx',
'__tests__/**',
'testUtils.tsx',
'*TestWrapper.tsx',
],
rules: {
'no-restricted-imports': 'off',
},
},
],
};
67 changes: 67 additions & 0 deletions __tests__/eslintrules/eslintI18nImportRules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import eslintConfig from '../../.eslintrc';
import { importRuleFor, lintImportSnippet } from './restrictedSyntaxHarness';

const i18nRule = importRuleFor('i18n');

// no-restricted-imports prefixes the configured message with its own sentence,
// unlike no-restricted-syntax which reports the message verbatim.
const restricted = (specifier: string): string =>
`'${specifier}' import is restricted from being used by a pattern. ${i18nRule.message}`;

const lintImport = (code: string): string[] =>
lintImportSnippet(code).map((message) => message.message);

const exemptedPaths = (eslintConfig.overrides ?? [])
.filter((override) => override.rules?.['no-restricted-imports'] === 'off')
.flatMap((override) => override.files);

describe('i18n singleton no-restricted-imports rule', () => {
it('flags the singleton imported by its src path', () => {
expect(lintImport("import i18n from 'src/lib/i18n';")).toEqual([
restricted('src/lib/i18n'),
]);
});

it('flags the singleton imported from a parent directory', () => {
expect(lintImport("import i18n from '../i18n';")).toEqual([
restricted('../i18n'),
]);
});

it('flags the singleton imported from a sibling directory', () => {
expect(lintImport("import i18n from './i18n';")).toEqual([
restricted('./i18n'),
]);
});

it('flags a named import from the singleton', () => {
expect(lintImport("import { t } from 'src/lib/i18n';")).toEqual([
restricted('src/lib/i18n'),
]);
});

it('accepts the TFunction type from i18next', () => {
expect(lintImport("import { TFunction } from 'i18next';")).toEqual([]);
});

it('accepts useTranslation from react-i18next', () => {
expect(
lintImport("import { useTranslation } from 'react-i18next';"),
).toEqual([]);
});

it('accepts an unrelated src import', () => {
expect(lintImport("import theme from 'src/theme';")).toEqual([]);
});

it('exempts only pages and test helpers', () => {
expect(exemptedPaths).toEqual([
'pages/**',
'*.test.ts',
'*.test.tsx',
'__tests__/**',
'testUtils.tsx',
'*TestWrapper.tsx',
]);
});
});
42 changes: 37 additions & 5 deletions __tests__/eslintrules/restrictedSyntaxHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ interface RestrictedSyntaxOption {
message: string;
}

interface RestrictedImportsOption {
patterns: { group: string[]; message: string }[];
}

export interface LintMessage {
ruleId: string | null;
message: string;
Expand Down Expand Up @@ -43,18 +47,46 @@ export const ruleFor = (selectorFragment: string): RestrictedSyntaxOption => {
return option;
};

export const lintSnippet = (code: string): LintMessage[] => {
// Assert against the real rule
export const restrictedImports = eslintConfig.rules[
'no-restricted-imports'
] as ['error', RestrictedImportsOption];
const [, { patterns }] = restrictedImports;

export const importRuleFor = (
groupFragment: string,
): RestrictedImportsOption['patterns'][number] => {
const pattern = patterns.find(({ group }) =>
group.some((glob) => glob.includes(groupFragment)),
);
if (!pattern) {
throw new Error(
`No no-restricted-imports rule has a group containing "${groupFragment}"`,
);
}
return pattern;
};

const lintWith = (
code: string,
ruleId: string,
ruleConfig: Linter.RuleEntry,
): LintMessage[] => {
const messages: LintMessage[] = linter.verify(code, {
parser,
parserOptions,
rules: { 'no-restricted-syntax': restrictedSyntax },
rules: { [ruleId]: ruleConfig },
});
const fatal = messages.find((message) => message.fatal);
if (fatal) {
throw new Error(`Probe failed to parse: ${fatal.message}`);
}

return messages.filter(
(message) => message.ruleId === 'no-restricted-syntax',
);
return messages.filter((message) => message.ruleId === ruleId);
};

export const lintSnippet = (code: string): LintMessage[] =>
lintWith(code, 'no-restricted-syntax', restrictedSyntax);

export const lintImportSnippet = (code: string): LintMessage[] =>
lintWith(code, 'no-restricted-imports', restrictedImports);
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const Preferences: React.FC = () => {
? (query.selectedTab as PreferenceAccordion)
: null,
);
const countries = getCountries();
const countries = getCountries(t);
const timeZones = useGetTimezones();

const [_, setSetupPosition] = useUserPreference({
Expand Down
5 changes: 3 additions & 2 deletions src/components/Announcements/Announcements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import React, {
} from 'react';
import { getApolloContext } from '@apollo/client';
import { DateTime } from 'luxon';
import { useTranslation } from 'react-i18next';
import { AlertBanner } from 'src/components/Shared/alertBanner/AlertBanner';
import {
ActionEnum,
Expand All @@ -17,7 +18,6 @@ import {
import { useOptionalAccountListId } from 'src/hooks/useAccountListId';
import { useContactPartnershipStatuses } from 'src/hooks/useContactPartnershipStatuses';
import { dispatch } from 'src/lib/analytics';
import i18n from 'src/lib/i18n';
import { DynamicAddAppealModal } from '../Tool/Appeal/Modals/AddAppealModal/DynamicAddAppealModal';
import { DynamicAnnouncementBanner } from './AnnouncementBanner/DynamicAnnouncementBanner';
import { DynamicAnnouncementModal } from './AnnouncementModal/DynamicAnnouncementModal';
Expand All @@ -42,6 +42,7 @@ export const Announcements: React.FC = () => {
};

const Announcement: React.FC = () => {
const { t } = useTranslation();
const { push } = useRouter();
const accountListId = useOptionalAccountListId();
const [showAppealModal, setShowAppealModal] = useState(false);
Expand Down Expand Up @@ -127,7 +128,7 @@ const Announcement: React.FC = () => {
[accountListId, announcement],
);

const appealName = `${DateTime.local().year} ${i18n.t('End of Year Ask')}`;
const appealName = `${DateTime.local().year} ${t('End of Year Ask')}`;
const appealStatuses = useMemo(
() => [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import NextLink from 'next/link';
import React from 'react';
import { Box, Link, Typography } from '@mui/material';
import { styled } from '@mui/material/styles';
import { TFunction } from 'i18next';
import { useTranslation } from 'react-i18next';
import { useApiConstants } from 'src/components/Constants/UseApiConstants';
import { useContactPanel } from 'src/components/Shared/ContactPanelProvider/ContactPanelProvider';
import { PreferredContactMethodEnum } from 'src/graphql/types.generated';
import { formatLanguage } from 'src/lib/data/languages';
import i18n from 'src/lib/i18n';
import { ContactOtherFragment } from './ContactOther.generated';

const ContactOtherContainer = styled(Box)(({ theme }) => ({
Expand All @@ -33,24 +33,27 @@ interface ContactDetailsOtherProp {
contact: ContactOtherFragment;
}

export const localizedContactMethod = (method?: string | null): string => {
export const localizedContactMethod = (
method: string | null | undefined,
t: TFunction,
): string => {
switch (method) {
case PreferredContactMethodEnum.Sms:
return i18n.t('SMS');
return t('SMS');
case PreferredContactMethodEnum.PhoneCall:
return i18n.t('Phone Call');
return t('Phone Call');
case PreferredContactMethodEnum.Email:
return i18n.t('Email');
return t('Email');
case PreferredContactMethodEnum.Facebook:
return i18n.t('Facebook');
return t('Facebook');
case PreferredContactMethodEnum.Instagram:
return i18n.t('Instagram');
return t('Instagram');
case PreferredContactMethodEnum.WeChat:
return i18n.t('WeChat');
return t('WeChat');
case PreferredContactMethodEnum.WhatsApp:
return i18n.t('WhatsApp');
return t('WhatsApp');
default:
return i18n.t('N/A');
return t('N/A');
}
};

Expand Down Expand Up @@ -123,7 +126,7 @@ export const ContactDetailsOther: React.FC<ContactDetailsOtherProp> = ({
{t('Preferred Contact Method')}
</ContactOtherTextLabel>
<Typography variant="subtitle1">
{localizedContactMethod(preferredContactMethod)}
{localizedContactMethod(preferredContactMethod, t)}
</Typography>
</ContactOtherTextContainer>
<ContactOtherTextContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,8 +374,10 @@
>
{Object.values(PreferredContactMethodEnum).map(
(value) => {
const contactMethod =
localizedContactMethod(value);
const contactMethod = localizedContactMethod(
value,
t,
);

Check warning on line 380 in src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Complex Method

EditContactOtherModal:React.FC<EditContactOtherModalProps> already has high cyclomatic complexity, and now it increases in Lines of Code from 516 to 518 This function has many conditional statements (e.g. if, for, while), leading to lower code health. Avoid adding more conditionals and code to it without refactoring.
return (
<MenuItem
key={value}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactElement, useState } from 'react';
import React, { ReactElement, useMemo, useState } from 'react';
import Add from '@mui/icons-material/Add';
import Delete from '@mui/icons-material/Delete';
import { Box, IconButton, TextField, Typography } from '@mui/material';
Expand All @@ -8,7 +8,6 @@ import { useSnackbar } from 'notistack';
import { useTranslation } from 'react-i18next';
import * as yup from 'yup';
import { ActionButton } from 'src/components/Shared/Modal/ActionButtons/ActionButtons';
import i18n from 'src/lib/i18n';
import { ContactDetailsTabDocument } from '../ContactDetailsTab.generated';
import { useUpdateContactOtherMutation } from '../Other/EditContactOtherModal/EditContactOther.generated';
import { ContactDetailLoadingPlaceHolder } from '../StyledComponents';
Expand All @@ -18,12 +17,6 @@ import {
} from './ContactPartnerAccounts.generated';
import { useDeleteDonorAccountMutation } from './DeleteDonorAccount.generated';

const newPartnerAccountSchema = yup.object({
accountNumber: yup.string().required(i18n.t('Account Number is required')),
});

type Attributes = yup.InferType<typeof newPartnerAccountSchema>;

const ContactPartnerAccountsContainer = styled(Box)(({ theme }) => ({
margin: theme.spacing(1, 1, 1, 5),
}));
Expand Down Expand Up @@ -72,6 +65,16 @@ export const ContactDetailsPartnerAccounts: React.FC<
variables: { accountListId },
});
const { t } = useTranslation();

const newPartnerAccountSchema = useMemo(
() =>
yup.object({
accountNumber: yup.string().required(t('Account Number is required')),
}),
[t],
);

type Attributes = yup.InferType<typeof newPartnerAccountSchema>;
const { enqueueSnackbar } = useSnackbar();

const deleteContactDonorAccount = async (id: string) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactElement, useState } from 'react';
import React, { ReactElement, useMemo, useState } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import {
DialogActions,
Expand Down Expand Up @@ -28,7 +28,6 @@
LogTextField,
} from 'src/components/Shared/styledComponents/LogStyling';
import { ActivityTypeEnum } from 'src/graphql/types.generated';
import i18n from 'src/lib/i18n';
import { nullableDateTime } from 'src/lib/yupHelpers';
import { useCreateTasksMutation } from '../../../../../Task/Modal/Form/TaskModal.generated';
import { CloseButton } from '../styledComponents/CloseButton';
Expand All @@ -50,23 +49,27 @@
},
}));

const taskSchema = yup.object({
activityType: yup
.mixed<ActivityTypeEnum | 'BOTH'>()
.oneOf([...Object.values(ActivityTypeEnum), 'BOTH' as const])
.defined(),
completedAt: nullableDateTime(),
subject: yup.string().required(i18n.t('Subject is required')),
});

type Attributes = yup.InferType<typeof taskSchema>;

const LogNewsletter = ({
accountListId,
handleClose,
}: Props): ReactElement<Props> => {
const { t } = useTranslation();

const taskSchema = useMemo(
() =>
yup.object({
activityType: yup
.mixed<ActivityTypeEnum | 'BOTH'>()
.oneOf([...Object.values(ActivityTypeEnum), 'BOTH' as const])
.defined(),
completedAt: nullableDateTime(),
subject: yup.string().required(t('Subject is required')),
}),
[t],
);

type Attributes = yup.InferType<typeof taskSchema>;

Check warning on line 72 in src/components/Dashboard/ThisWeek/NewsletterMenu/MenuItems/LogNewsLetter/LogNewsletter.tsx

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Large Method

LogNewsletter increases from 174 to 187 lines of code, threshold = 120 Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
const [commentBody, changeCommentBody] = useState('');

const [createTasks, { loading: creating }] = useCreateTasksMutation();
Expand Down
Loading
Loading