diff --git a/packages/eslint-plugin/docs/rules/README.md b/packages/eslint-plugin/docs/rules/README.md index 04e2d987056..71da9b1fd15 100644 --- a/packages/eslint-plugin/docs/rules/README.md +++ b/packages/eslint-plugin/docs/rules/README.md @@ -13,11 +13,11 @@ See [Configs](/linting/configs) for how to enable recommended rules using config import RulesTable from "@site/src/components/RulesTable"; - + ## Extension Rules In some cases, ESLint provides a rule itself, but it doesn't support TypeScript syntax; either it crashes, or it ignores the syntax, or it falsely reports against it. In these cases, we create what we call an extension rule; a rule within our plugin that has the same functionality, but also supports TypeScript. - + diff --git a/packages/website/src/components/RulesTable/index.tsx b/packages/website/src/components/RulesTable/index.tsx index 7b7033abe92..f5cc55eaf7d 100644 --- a/packages/website/src/components/RulesTable/index.tsx +++ b/packages/website/src/components/RulesTable/index.tsx @@ -1,9 +1,14 @@ import Link from '@docusaurus/Link'; +import { useHistory } from '@docusaurus/router'; import type { RulesMeta } from '@site/rulesMeta'; import { useRulesMeta } from '@site/src/hooks/useRulesMeta'; import clsx from 'clsx'; -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +import { + type HistorySelector, + useHistorySelector, +} from '../../hooks/useHistorySelector'; import styles from './styles.module.css'; function interpolateCode(text: string): (JSX.Element | string)[] | string { @@ -118,17 +123,14 @@ function match(mode: FilterMode, value: boolean): boolean | undefined { } export default function RulesTable({ - extensionRules, + ruleset, }: { - extensionRules?: boolean; + ruleset: 'extension-rules' | 'supported-rules'; }): JSX.Element { + const [filters, changeFilter] = useRulesFilters(ruleset); + const rules = useRulesMeta(); - const [showRecommended, setShowRecommended] = useState('neutral'); - const [showStrict, setShowStrict] = useState('neutral'); - const [showFixable, setShowFixable] = useState('neutral'); - const [showHasSuggestions, setShowHasSuggestion] = - useState('neutral'); - const [showTypeCheck, setShowTypeCheck] = useState('neutral'); + const extensionRules = ruleset === 'extension-rules'; const relevantRules = useMemo( () => rules @@ -136,64 +138,45 @@ export default function RulesTable({ .filter(r => { const opinions = [ match( - showRecommended, + filters.recommended, r.docs?.recommended === 'error' || r.docs?.recommended === 'warn', ), - match(showStrict, r.docs?.recommended === 'strict'), - match(showFixable, !!r.fixable), - match(showHasSuggestions, !!r.hasSuggestions), - match(showTypeCheck, !!r.docs?.requiresTypeChecking), + match(filters.strict, r.docs?.recommended === 'strict'), + match(filters.fixable, !!r.fixable), + match(filters.suggestions, !!r.hasSuggestions), + match(filters.typeInformation, !!r.docs?.requiresTypeChecking), ].filter((o): o is boolean => o !== undefined); return opinions.every(o => o); }), - [ - rules, - extensionRules, - showRecommended, - showStrict, - showFixable, - showHasSuggestions, - showTypeCheck, - ], + [rules, extensionRules, filters], ); + return ( <>
    { - setShowRecommended(newMode); - - if (newMode === 'include' && showStrict === 'include') { - setShowStrict('exclude'); - } - }} + mode={filters.recommended} + setMode={(newMode): void => changeFilter('recommended', newMode)} label="✅ recommended" /> { - setShowStrict(newMode); - - if (newMode === 'include' && showRecommended === 'include') { - setShowRecommended('exclude'); - } - }} + mode={filters.strict} + setMode={(newMode): void => changeFilter('strict', newMode)} label="🔒 strict" /> changeFilter('fixable', newMode)} label="🔧 fixable" /> changeFilter('suggestions', newMode)} label="💡 has suggestions" /> changeFilter('typeInformation', newMode)} label="💭 requires type information" />
@@ -224,3 +207,97 @@ export default function RulesTable({ ); } + +type FilterCategory = + | 'recommended' + | 'strict' + | 'fixable' + | 'suggestions' + | 'typeInformation'; +type FiltersState = Record; +const neutralFiltersState: FiltersState = { + recommended: 'neutral', + strict: 'neutral', + fixable: 'neutral', + suggestions: 'neutral', + typeInformation: 'neutral', +}; + +const selectSearch: HistorySelector = history => + history.location.search; +const getServerSnapshot = (): string => ''; + +function useRulesFilters( + paramsKey: string, +): [FiltersState, (category: FilterCategory, mode: FilterMode) => void] { + const history = useHistory(); + const search = useHistorySelector(selectSearch, getServerSnapshot); + + const paramValue = new URLSearchParams(search).get(paramsKey) ?? ''; + // We can't compute this in selectSearch, because we need the snapshot to be + // comparable by value. + const filtersState = useMemo( + () => parseFiltersState(paramValue), + [paramValue], + ); + + const changeFilter = (category: FilterCategory, mode: FilterMode): void => { + const newState = { ...filtersState, [category]: mode }; + + if ( + category === 'strict' && + mode === 'include' && + filtersState.recommended === 'include' + ) { + newState.recommended = 'exclude'; + } else if ( + category === 'recommended' && + mode === 'include' && + filtersState.strict === 'include' + ) { + newState.strict = 'exclude'; + } + + const searchParams = new URLSearchParams(history.location.search); + const filtersString = stringifyFiltersState(newState); + + if (filtersString) { + searchParams.set(paramsKey, filtersString); + } else { + searchParams.delete(paramsKey); + } + + history.replace({ search: searchParams.toString() }); + }; + + return [filtersState, changeFilter]; +} + +const NEGATION_SYMBOL = 'x'; + +function stringifyFiltersState(filters: FiltersState): string { + return Object.entries(filters) + .map(([key, value]) => + value === 'include' + ? key + : value === 'exclude' + ? `${NEGATION_SYMBOL}${key}` + : '', + ) + .filter(Boolean) + .join('-'); +} + +function parseFiltersState(str: string): FiltersState { + const res: FiltersState = { ...neutralFiltersState }; + + for (const part of str.split('-')) { + const exclude = part.startsWith(NEGATION_SYMBOL); + const key = exclude ? part.slice(1) : part; + if (Object.hasOwn(neutralFiltersState, key)) { + res[key] = exclude ? 'exclude' : 'include'; + } + } + + return res; +} diff --git a/packages/website/src/hooks/useHistorySelector.ts b/packages/website/src/hooks/useHistorySelector.ts new file mode 100644 index 00000000000..841c100b83b --- /dev/null +++ b/packages/website/src/hooks/useHistorySelector.ts @@ -0,0 +1,17 @@ +import { useHistory } from '@docusaurus/router'; +import type * as H from 'history'; +import { useSyncExternalStore } from 'react'; + +export type HistorySelector = (history: H.History) => T; + +export function useHistorySelector( + selector: HistorySelector, + getServerSnapshot: () => T, +): T { + const history = useHistory(); + return useSyncExternalStore( + history.listen, + () => selector(history), + getServerSnapshot, + ); +} diff --git a/packages/website/tests/rules.spec.ts b/packages/website/tests/rules.spec.ts new file mode 100644 index 00000000000..faba1bdd447 --- /dev/null +++ b/packages/website/tests/rules.spec.ts @@ -0,0 +1,32 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +test.describe('Rules Page', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/rules'); + }); + + test('Accessibility', async ({ page }) => { + await new AxeBuilder({ page }).analyze(); + }); + + test('Rules filters are saved to the URL', async ({ page }) => { + await page.getByText('🔧 fixable').first().click(); + await page.getByText('✅ recommended').first().click(); + await page.getByText('✅ recommended').first().click(); + + expect(new URL(page.url()).search).toBe( + '?supported-rules=xrecommended-fixable', + ); + }); + + test('Rules filters are read from the URL on page load', async ({ page }) => { + await page.goto('/rules?supported-rules=strict-xfixable'); + + const strict = page.getByText('🔒 strict').first(); + const fixable = page.getByText('🔧 fixable').first(); + + await expect(strict).toHaveAttribute('aria-label', /Current: include/); + await expect(fixable).toHaveAttribute('aria-label', /Current: exclude/); + }); +});