Uh oh!
There was an error while loading.Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork2.8k
chore(website): preserve RulesTable filters state in searchParams#6568
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
0f06ead015218f92a9c55d3e8ae9aa32a3c5e574a83ca24e1d37c066File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } from 'react'; | ||
| import { | ||
| type HistorySelector, | ||
| useHistorySelector, | ||
| } from '../../hooks/useHistorySelector'; | ||
| import styles from './styles.module.css'; | ||
| function interpolateCode(text: string): (JSX.Element | string)[] | string { | ||
| @@ -118,82 +123,60 @@ function match(mode: FilterMode, value: boolean): boolean | undefined { | ||
| } | ||
| export default function RulesTable({ | ||
| ruleset, | ||
| }: { | ||
| ruleset: 'extension-rules' | 'supported-rules'; | ||
| }): JSX.Element { | ||
| const [filters, changeFilter] = useRulesFilters(ruleset); | ||
| const rules = useRulesMeta(); | ||
| const extensionRules = ruleset === 'extension-rules'; | ||
| const relevantRules = useMemo( | ||
| () => | ||
| rules | ||
| .filter(r => !!extensionRules === !!r.docs?.extendsBaseRule) | ||
| .filter(r => { | ||
| const opinions = [ | ||
| match( | ||
| filters.recommended, | ||
| r.docs?.recommended === 'error' || r.docs?.recommended === 'warn', | ||
| ), | ||
| 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, filters], | ||
| ); | ||
| return ( | ||
| <> | ||
| <ul className={clsx('clean-list', styles.checkboxList)}> | ||
| <RuleFilterCheckBox | ||
| mode={filters.recommended} | ||
| setMode={(newMode): void => changeFilter('recommended', newMode)} | ||
| label="✅ recommended" | ||
| /> | ||
| <RuleFilterCheckBox | ||
| mode={filters.strict} | ||
| setMode={(newMode): void => changeFilter('strict', newMode)} | ||
| label="🔒 strict" | ||
| /> | ||
| <RuleFilterCheckBox | ||
| mode={filters.fixable} | ||
| setMode={(newMode): void => changeFilter('fixable', newMode)} | ||
| label="🔧 fixable" | ||
| /> | ||
| <RuleFilterCheckBox | ||
| mode={filters.suggestions} | ||
| setMode={(newMode): void => changeFilter('suggestions', newMode)} | ||
| label="💡 has suggestions" | ||
| /> | ||
| <RuleFilterCheckBox | ||
| mode={filters.typeInformation} | ||
| setMode={(newMode): void => changeFilter('typeInformation', newMode)} | ||
| label="💭 requires type information" | ||
| /> | ||
| </ul> | ||
| @@ -224,3 +207,97 @@ export default function RulesTable({ | ||
| </> | ||
| ); | ||
| } | ||
| type FilterCategory = | ||
| | 'recommended' | ||
| | 'strict' | ||
| | 'fixable' | ||
| | 'suggestions' | ||
| | 'typeInformation'; | ||
| type FiltersState = Record<FilterCategory, FilterMode>; | ||
| const neutralFiltersState: FiltersState = { | ||
| recommended: 'neutral', | ||
| strict: 'neutral', | ||
| fixable: 'neutral', | ||
| suggestions: 'neutral', | ||
| typeInformation: 'neutral', | ||
| }; | ||
| const selectSearch: HistorySelector<string> = 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); | ||
armano2 marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
| 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { useHistory } from '@docusaurus/router'; | ||
| import type * as H from 'history'; | ||
| import { useSyncExternalStore } from 'react'; | ||
| export type HistorySelector<T> = (history: H.History<H.LocationState>) => T; | ||
| export function useHistorySelector<T>( | ||
| selector: HistorySelector<T>, | ||
| getServerSnapshot: () => T, | ||
| ): T { | ||
| const history = useHistory(); | ||
| return useSyncExternalStore( | ||
| history.listen, | ||
| () => selector(history), | ||
| getServerSnapshot, | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| importAxeBuilderfrom'@axe-core/playwright'; | ||
| import{expect,test}from'@playwright/test'; | ||
| test.describe('Rules Page',()=>{ | ||
| test.beforeEach(async({ page})=>{ | ||
| awaitpage.goto('/rules'); | ||
| }); | ||
| test('Accessibility',async({ page})=>{ | ||
| awaitnewAxeBuilder({ page}).analyze(); | ||
| }); | ||
Member There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. 😍 thanks for adding this in! (I'll continue reviewing soon, just wanted to express appreciation sooner) | ||
| test('Rules filters are saved to the URL',async({ page})=>{ | ||
| awaitpage.getByText('🔧 fixable').first().click(); | ||
| awaitpage.getByText('✅ recommended').first().click(); | ||
| awaitpage.getByText('✅ recommended').first().click(); | ||
| expect(newURL(page.url()).search).toBe( | ||
| '?supported-rules=xrecommended-fixable', | ||
| ); | ||
| }); | ||
| test('Rules filters are read from the URL on page load',async({ page})=>{ | ||
| awaitpage.goto('/rules?supported-rules=strict-xfixable'); | ||
| conststrict=page.getByText('🔒 strict').first(); | ||
| constfixable=page.getByText('🔧 fixable').first(); | ||
| awaitexpect(strict).toHaveAttribute('aria-label',/Current:include/); | ||
| awaitexpect(fixable).toHaveAttribute('aria-label',/Current:exclude/); | ||
| }); | ||
| }); | ||