Skip to content

Commit

Permalink
chore(website): preserve RulesTable filters state in searchParams (#6568
Browse files Browse the repository at this point in the history
)

* 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
  • Loading branch information
hasparus committed Mar 24, 2023
1 parent 08dec75 commit d8e563b
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 47 deletions.
4 changes: 2 additions & 2 deletions packages/eslint-plugin/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ See [Configs](/linting/configs) for how to enable recommended rules using config

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

<RulesTable />
<RulesTable ruleset="supported-rules" />

## 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.

<RulesTable extensionRules />
<RulesTable ruleset="extension-rules" />
167 changes: 122 additions & 45 deletions packages/website/src/components/RulesTable/index.tsx
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, 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 {
Expand Down Expand Up @@ -118,82 +123,60 @@ 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<FilterMode>('neutral');
const [showStrict, setShowStrict] = useState<FilterMode>('neutral');
const [showFixable, setShowFixable] = useState<FilterMode>('neutral');
const [showHasSuggestions, setShowHasSuggestion] =
useState<FilterMode>('neutral');
const [showTypeCheck, setShowTypeCheck] = useState<FilterMode>('neutral');
const extensionRules = ruleset === 'extension-rules';
const relevantRules = useMemo(
() =>
rules
.filter(r => !!extensionRules === !!r.docs?.extendsBaseRule)
.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 (
<>
<ul className={clsx('clean-list', styles.checkboxList)}>
<RuleFilterCheckBox
mode={showRecommended}
setMode={(newMode): void => {
setShowRecommended(newMode);

if (newMode === 'include' && showStrict === 'include') {
setShowStrict('exclude');
}
}}
mode={filters.recommended}
setMode={(newMode): void => changeFilter('recommended', newMode)}
label="✅ recommended"
/>
<RuleFilterCheckBox
mode={showStrict}
setMode={(newMode): void => {
setShowStrict(newMode);

if (newMode === 'include' && showRecommended === 'include') {
setShowRecommended('exclude');
}
}}
mode={filters.strict}
setMode={(newMode): void => changeFilter('strict', newMode)}
label="🔒 strict"
/>
<RuleFilterCheckBox
mode={showFixable}
setMode={setShowFixable}
mode={filters.fixable}
setMode={(newMode): void => changeFilter('fixable', newMode)}
label="🔧 fixable"
/>
<RuleFilterCheckBox
mode={showHasSuggestions}
setMode={setShowHasSuggestion}
mode={filters.suggestions}
setMode={(newMode): void => changeFilter('suggestions', newMode)}
label="💡 has suggestions"
/>
<RuleFilterCheckBox
mode={showTypeCheck}
setMode={setShowTypeCheck}
mode={filters.typeInformation}
setMode={(newMode): void => changeFilter('typeInformation', newMode)}
label="💭 requires type information"
/>
</ul>
Expand Down Expand Up @@ -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);

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;
}
17 changes: 17 additions & 0 deletions packages/website/src/hooks/useHistorySelector.ts
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,
);
}
32 changes: 32 additions & 0 deletions packages/website/tests/rules.spec.ts
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();
});

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/);
});
});

0 comments on commit d8e563b

Please sign in to comment.