Skip to content

Commit d8e563b

Browse files
authored
chore(website): preserve RulesTable filters state in searchParams (#6568)
* feat(website): preserve RulesTable filters state in searchParams * Move rules filters state to useLocation * Revert "Move rules filters state to useLocation" This reverts commit 7c2e81a. * Test rules filters in URL * Use .ruleset prop in RulesTable * Use useHistorySelector instead of useIsomorphicLayoutEffect * Move useHistorySelector to src/hooks * Fix lint errors
1 parent 08dec75 commit d8e563b

File tree

4 files changed

+173
-47
lines changed

4 files changed

+173
-47
lines changed

packages/eslint-plugin/docs/rules/README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ See [Configs](/linting/configs) for how to enable recommended rules using config
1313

1414
import RulesTable from "@site/src/components/RulesTable";
1515

16-
<RulesTable />
16+
<RulesTable ruleset="supported-rules" />
1717

1818
## Extension Rules
1919

2020
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.
2121
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.
2222

23-
<RulesTable extensionRules />
23+
<RulesTable ruleset="extension-rules" />

packages/website/src/components/RulesTable/index.tsx

+122-45
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import Link from '@docusaurus/Link';
2+
import { useHistory } from '@docusaurus/router';
23
import type { RulesMeta } from '@site/rulesMeta';
34
import { useRulesMeta } from '@site/src/hooks/useRulesMeta';
45
import clsx from 'clsx';
5-
import React, { useMemo, useState } from 'react';
6+
import React, { useMemo } from 'react';
67

8+
import {
9+
type HistorySelector,
10+
useHistorySelector,
11+
} from '../../hooks/useHistorySelector';
712
import styles from './styles.module.css';
813

914
function interpolateCode(text: string): (JSX.Element | string)[] | string {
@@ -118,82 +123,60 @@ function match(mode: FilterMode, value: boolean): boolean | undefined {
118123
}
119124

120125
export default function RulesTable({
121-
extensionRules,
126+
ruleset,
122127
}: {
123-
extensionRules?: boolean;
128+
ruleset: 'extension-rules' | 'supported-rules';
124129
}): JSX.Element {
130+
const [filters, changeFilter] = useRulesFilters(ruleset);
131+
125132
const rules = useRulesMeta();
126-
const [showRecommended, setShowRecommended] = useState<FilterMode>('neutral');
127-
const [showStrict, setShowStrict] = useState<FilterMode>('neutral');
128-
const [showFixable, setShowFixable] = useState<FilterMode>('neutral');
129-
const [showHasSuggestions, setShowHasSuggestion] =
130-
useState<FilterMode>('neutral');
131-
const [showTypeCheck, setShowTypeCheck] = useState<FilterMode>('neutral');
133+
const extensionRules = ruleset === 'extension-rules';
132134
const relevantRules = useMemo(
133135
() =>
134136
rules
135137
.filter(r => !!extensionRules === !!r.docs?.extendsBaseRule)
136138
.filter(r => {
137139
const opinions = [
138140
match(
139-
showRecommended,
141+
filters.recommended,
140142
r.docs?.recommended === 'error' || r.docs?.recommended === 'warn',
141143
),
142-
match(showStrict, r.docs?.recommended === 'strict'),
143-
match(showFixable, !!r.fixable),
144-
match(showHasSuggestions, !!r.hasSuggestions),
145-
match(showTypeCheck, !!r.docs?.requiresTypeChecking),
144+
match(filters.strict, r.docs?.recommended === 'strict'),
145+
match(filters.fixable, !!r.fixable),
146+
match(filters.suggestions, !!r.hasSuggestions),
147+
match(filters.typeInformation, !!r.docs?.requiresTypeChecking),
146148
].filter((o): o is boolean => o !== undefined);
147149
return opinions.every(o => o);
148150
}),
149-
[
150-
rules,
151-
extensionRules,
152-
showRecommended,
153-
showStrict,
154-
showFixable,
155-
showHasSuggestions,
156-
showTypeCheck,
157-
],
151+
[rules, extensionRules, filters],
158152
);
153+
159154
return (
160155
<>
161156
<ul className={clsx('clean-list', styles.checkboxList)}>
162157
<RuleFilterCheckBox
163-
mode={showRecommended}
164-
setMode={(newMode): void => {
165-
setShowRecommended(newMode);
166-
167-
if (newMode === 'include' && showStrict === 'include') {
168-
setShowStrict('exclude');
169-
}
170-
}}
158+
mode={filters.recommended}
159+
setMode={(newMode): void => changeFilter('recommended', newMode)}
171160
label="✅ recommended"
172161
/>
173162
<RuleFilterCheckBox
174-
mode={showStrict}
175-
setMode={(newMode): void => {
176-
setShowStrict(newMode);
177-
178-
if (newMode === 'include' && showRecommended === 'include') {
179-
setShowRecommended('exclude');
180-
}
181-
}}
163+
mode={filters.strict}
164+
setMode={(newMode): void => changeFilter('strict', newMode)}
182165
label="🔒 strict"
183166
/>
184167
<RuleFilterCheckBox
185-
mode={showFixable}
186-
setMode={setShowFixable}
168+
mode={filters.fixable}
169+
setMode={(newMode): void => changeFilter('fixable', newMode)}
187170
label="🔧 fixable"
188171
/>
189172
<RuleFilterCheckBox
190-
mode={showHasSuggestions}
191-
setMode={setShowHasSuggestion}
173+
mode={filters.suggestions}
174+
setMode={(newMode): void => changeFilter('suggestions', newMode)}
192175
label="💡 has suggestions"
193176
/>
194177
<RuleFilterCheckBox
195-
mode={showTypeCheck}
196-
setMode={setShowTypeCheck}
178+
mode={filters.typeInformation}
179+
setMode={(newMode): void => changeFilter('typeInformation', newMode)}
197180
label="💭 requires type information"
198181
/>
199182
</ul>
@@ -224,3 +207,97 @@ export default function RulesTable({
224207
</>
225208
);
226209
}
210+
211+
type FilterCategory =
212+
| 'recommended'
213+
| 'strict'
214+
| 'fixable'
215+
| 'suggestions'
216+
| 'typeInformation';
217+
type FiltersState = Record<FilterCategory, FilterMode>;
218+
const neutralFiltersState: FiltersState = {
219+
recommended: 'neutral',
220+
strict: 'neutral',
221+
fixable: 'neutral',
222+
suggestions: 'neutral',
223+
typeInformation: 'neutral',
224+
};
225+
226+
const selectSearch: HistorySelector<string> = history =>
227+
history.location.search;
228+
const getServerSnapshot = (): string => '';
229+
230+
function useRulesFilters(
231+
paramsKey: string,
232+
): [FiltersState, (category: FilterCategory, mode: FilterMode) => void] {
233+
const history = useHistory();
234+
const search = useHistorySelector(selectSearch, getServerSnapshot);
235+
236+
const paramValue = new URLSearchParams(search).get(paramsKey) ?? '';
237+
// We can't compute this in selectSearch, because we need the snapshot to be
238+
// comparable by value.
239+
const filtersState = useMemo(
240+
() => parseFiltersState(paramValue),
241+
[paramValue],
242+
);
243+
244+
const changeFilter = (category: FilterCategory, mode: FilterMode): void => {
245+
const newState = { ...filtersState, [category]: mode };
246+
247+
if (
248+
category === 'strict' &&
249+
mode === 'include' &&
250+
filtersState.recommended === 'include'
251+
) {
252+
newState.recommended = 'exclude';
253+
} else if (
254+
category === 'recommended' &&
255+
mode === 'include' &&
256+
filtersState.strict === 'include'
257+
) {
258+
newState.strict = 'exclude';
259+
}
260+
261+
const searchParams = new URLSearchParams(history.location.search);
262+
const filtersString = stringifyFiltersState(newState);
263+
264+
if (filtersString) {
265+
searchParams.set(paramsKey, filtersString);
266+
} else {
267+
searchParams.delete(paramsKey);
268+
}
269+
270+
history.replace({ search: searchParams.toString() });
271+
};
272+
273+
return [filtersState, changeFilter];
274+
}
275+
276+
const NEGATION_SYMBOL = 'x';
277+
278+
function stringifyFiltersState(filters: FiltersState): string {
279+
return Object.entries(filters)
280+
.map(([key, value]) =>
281+
value === 'include'
282+
? key
283+
: value === 'exclude'
284+
? `${NEGATION_SYMBOL}${key}`
285+
: '',
286+
)
287+
.filter(Boolean)
288+
.join('-');
289+
}
290+
291+
function parseFiltersState(str: string): FiltersState {
292+
const res: FiltersState = { ...neutralFiltersState };
293+
294+
for (const part of str.split('-')) {
295+
const exclude = part.startsWith(NEGATION_SYMBOL);
296+
const key = exclude ? part.slice(1) : part;
297+
if (Object.hasOwn(neutralFiltersState, key)) {
298+
res[key] = exclude ? 'exclude' : 'include';
299+
}
300+
}
301+
302+
return res;
303+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useHistory } from '@docusaurus/router';
2+
import type * as H from 'history';
3+
import { useSyncExternalStore } from 'react';
4+
5+
export type HistorySelector<T> = (history: H.History<H.LocationState>) => T;
6+
7+
export function useHistorySelector<T>(
8+
selector: HistorySelector<T>,
9+
getServerSnapshot: () => T,
10+
): T {
11+
const history = useHistory();
12+
return useSyncExternalStore(
13+
history.listen,
14+
() => selector(history),
15+
getServerSnapshot,
16+
);
17+
}

packages/website/tests/rules.spec.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import AxeBuilder from '@axe-core/playwright';
2+
import { expect, test } from '@playwright/test';
3+
4+
test.describe('Rules Page', () => {
5+
test.beforeEach(async ({ page }) => {
6+
await page.goto('/rules');
7+
});
8+
9+
test('Accessibility', async ({ page }) => {
10+
await new AxeBuilder({ page }).analyze();
11+
});
12+
13+
test('Rules filters are saved to the URL', async ({ page }) => {
14+
await page.getByText('🔧 fixable').first().click();
15+
await page.getByText('✅ recommended').first().click();
16+
await page.getByText('✅ recommended').first().click();
17+
18+
expect(new URL(page.url()).search).toBe(
19+
'?supported-rules=xrecommended-fixable',
20+
);
21+
});
22+
23+
test('Rules filters are read from the URL on page load', async ({ page }) => {
24+
await page.goto('/rules?supported-rules=strict-xfixable');
25+
26+
const strict = page.getByText('🔒 strict').first();
27+
const fixable = page.getByText('🔧 fixable').first();
28+
29+
await expect(strict).toHaveAttribute('aria-label', /Current: include/);
30+
await expect(fixable).toHaveAttribute('aria-label', /Current: exclude/);
31+
});
32+
});

0 commit comments

Comments
 (0)