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
0f06ead
015218f
92a9c55
d3e8ae9
aa32a3c
5e574a8
3ca24e1
d37c066
File 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 @@ | ||
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(); | ||
}); | ||
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 }) => { | ||
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/); | ||
}); | ||
}); |