chore: add docs and tests

chore: remove compo

chore: add docs and tests for useFilter

chore: add tests & docs for useCollator
This commit is contained in:
Josh 2023-04-17 11:24:21 +01:00
parent 7f1b43adf9
commit ef4f3b64fe
13 changed files with 11672 additions and 8017 deletions

View File

@ -0,0 +1,49 @@
---
title: useCollator
description: API reference for the useCollator hook in Strapi
tags:
- hooks
- helper-plugin
- i18n
---
A custom hook that returns the [`Intl.Collator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator) native API.
Useful for searching & sorting strings that are localised. The implementation method has a unique cache store to avoid creating a new instance of the
`Intl.Collator` for each call of the hook depending on the locale and options provided.
Stolen from [`react-aria`](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/i18n/src/useCollator.ts).
## Usage
```js
import { useIntl } from 'react-intl';
import { useCollator } from '@strapi/helper-plugin';
const MyComponent = () => {
const { locale } = useIntl();
const fruits = ['banana', 'apple', 'orange', 'kiwi'];
const formatter = useCollator(locale, { sensitivity: 'base' });
/**
* This would render the list of fruits in the following order:
* apple
* banana
* kiwi
* orange
*/
return (
<div>
{fruits.sort(formatter.compare).map((fruit) => (
<h2 key={fruit}>{fruit}</h2>
))}
</div>
);
};
```
## Typescript
```ts
function useCollator(locale: string, options?: Intl.CollatorOptions): Intl.Collator;
```

View File

@ -0,0 +1,59 @@
---
title: useFilter
description: API reference for the useFilter hook in Strapi
tags:
- hooks
- helper-plugin
- i18n
---
Provides localized string search functionality that is useful for filtering or matching items in a list. Options can be
provided to adjust the sensitivity to case, diacritics, and other parameters.
Stolen from [`react-aria`](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/i18n/src/useFilter.ts).
## Usage
```js
import { useIntl } from 'react-intl';
import { useFilter } from '@strapi/helper-plugin';
const MyComponent = () => {
const { locale } = useIntl();
const fruits = ['orange', 'apple', 'kiwi', 'banana'];
const searchValue = 'an';
const { contains } = useFilter(locale, { sensitivity: 'base' });
/**
* This would render the list of fruits in the following order:
* orange
* banana
*/
return (
<div>
{fruits
.filter((fruit) => includes(fruit, searchValue))
.map((fruit) => (
<h2 key={fruit}>{fruit}</h2>
))}
</div>
);
};
```
## Typescript
```ts
interface Filter {
/** Returns whether a string starts with a given substring. */
startsWith(string: string, substring: string): boolean;
/** Returns whether a string ends with a given substring. */
endsWith(string: string, substring: string): boolean;
/** Returns whether a string contains a given substring. */
includes(string: string, substring: string): boolean;
}
function useFilter(locale: string, options?: Intl.CollatorOptions): Filter;
```

View File

@ -115,16 +115,25 @@ const sidebars = {
type: 'category', type: 'category',
label: 'Hooks', label: 'Hooks',
items: [ items: [
{
type: 'doc',
label: 'useAPIErrorHandler',
id: 'core/helper-plugin/hooks/use-api-error-handler',
},
{
type: 'doc',
label: 'useCollator',
id: 'core/helper-plugin/hooks/use-collator',
},
{ {
type: 'doc', type: 'doc',
label: 'useFetchClient', label: 'useFetchClient',
id: 'core/helper-plugin/hooks/use-fetch-client', id: 'core/helper-plugin/hooks/use-fetch-client',
}, },
{ {
type: 'doc', type: 'doc',
label: 'useAPIErrorHandler', label: 'useFilter',
id: 'core/helper-plugin/hooks/use-api-error-handler', id: 'core/helper-plugin/hooks/use-filter',
}, },
], ],
}, },

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@ const LeftMenu = () => {
const modelLinksSelector = useMemo(makeSelectModelLinks, []); const modelLinksSelector = useMemo(makeSelectModelLinks, []);
const { collectionTypeLinks, singleTypeLinks } = useSelector(modelLinksSelector, shallowEqual); const { collectionTypeLinks, singleTypeLinks } = useSelector(modelLinksSelector, shallowEqual);
const { contains } = useFilter(locale, { const { includes } = useFilter(locale, {
sensitivity: 'base', sensitivity: 'base',
}); });
@ -65,7 +65,7 @@ const LeftMenu = () => {
/** /**
* Filter by the search value * Filter by the search value
*/ */
.filter((link) => contains(link.title, search)) .filter((link) => includes(link.title, search))
/** /**
* Sort correctly using the language * Sort correctly using the language
*/ */
@ -80,7 +80,7 @@ const LeftMenu = () => {
}; };
}), }),
})), })),
[collectionTypeLinks, search, singleTypeLinks, contains, formatMessage, formatter] [collectionTypeLinks, search, singleTypeLinks, includes, formatMessage, formatter]
); );
const handleClear = () => { const handleClear = () => {

View File

@ -49,7 +49,7 @@ const useSortedRoles = () => {
const [{ query }] = useQueryParams(); const [{ query }] = useQueryParams();
const _q = query?._q || ''; const _q = query?._q || '';
const { contains } = useFilter(locale, { const { includes } = useFilter(locale, {
sensitivity: 'base', sensitivity: 'base',
}); });
@ -61,7 +61,7 @@ const useSortedRoles = () => {
}); });
const sortedRoles = (roles || []) const sortedRoles = (roles || [])
.filter((role) => contains(role.name, _q) || contains(role.description, _q)) .filter((role) => includes(role.name, _q) || includes(role.description, _q))
.sort( .sort(
(a, b) => formatter.compare(a.name, b.name) || formatter.compare(a.description, b.description) (a, b) => formatter.compare(a.name, b.name) || formatter.compare(a.description, b.description)
); );

View File

@ -25,7 +25,7 @@ const useContentTypeBuilderMenu = () => {
const { onOpenModalCreateSchema, onOpenModalEditCategory } = useFormModalNavigation(); const { onOpenModalCreateSchema, onOpenModalEditCategory } = useFormModalNavigation();
const { locale } = useIntl(); const { locale } = useIntl();
const { contains } = useFilter(locale, { const { includes } = useFilter(locale, {
sensitivity: 'base', sensitivity: 'base',
}); });
@ -117,10 +117,10 @@ const useContentTypeBuilderMenu = () => {
} }
}, },
links: components links: components
.map((compo) => ({ .map((component) => ({
name: compo.uid, name: component.uid,
to: `/plugins/${pluginId}/component-categories/${category}/${compo.uid}`, to: `/plugins/${pluginId}/component-categories/${category}/${component.uid}`,
title: compo.schema.displayName, title: component.schema.displayName,
})) }))
.sort((a, b) => formatter.compare(a.title, b.title)), .sort((a, b) => formatter.compare(a.title, b.title)),
})) }))
@ -177,7 +177,7 @@ const useContentTypeBuilderMenu = () => {
links: section.links.map((link) => ({ links: section.links.map((link) => ({
...link, ...link,
links: link.links links: link.links
.filter((link) => contains(link.title, search)) .filter((link) => includes(link.title, search))
.sort((a, b) => formatter.compare(a.title, b.title)), .sort((a, b) => formatter.compare(a.title, b.title)),
})), })),
}; };
@ -186,7 +186,7 @@ const useContentTypeBuilderMenu = () => {
return { return {
...section, ...section,
links: section.links links: section.links
.filter((link) => contains(link.title, search)) .filter((link) => includes(link.title, search))
.sort((a, b) => formatter.compare(a.title, b.title)), .sort((a, b) => formatter.compare(a.title, b.title)),
}; };
}); });

View File

@ -34,7 +34,7 @@ const HomePage = () => {
const items = [{name: 'Paul', instrument: 'bass'}, {name: 'George', instrument: 'guitar'}] const items = [{name: 'Paul', instrument: 'bass'}, {name: 'George', instrument: 'guitar'}]
const { contains } = useFilter(locale, { const { includes } = useFilter(locale, {
sensitivity: 'base', sensitivity: 'base',
}); });
@ -44,7 +44,7 @@ const HomePage = () => {
const sortedList = items const sortedList = items
.filter((item) => contains(item.name, _q) || contains(item.instrument, _q)) .filter((item) => includes(item.name, _q) || includes(item.instrument, _q))
.sort( .sort(
(a, b) => formatter.compare(a.name, b.name) || formatter.compare(a.instrument, b.instrument) (a, b) => formatter.compare(a.name, b.name) || formatter.compare(a.instrument, b.instrument)
); );
@ -83,14 +83,14 @@ const HomePage = () => {
{ name: 'George', instrument: 'guitar' }, { name: 'George', instrument: 'guitar' },
]; ];
const { locale } = useIntl(); const { locale } = useIntl();
const { contains } = useFilter(locale, { const { includes } = useFilter(locale, {
sensitivity: 'base', sensitivity: 'base',
}); });
const formatter = useCollator(locale, { const formatter = useCollator(locale, {
sensitivity: 'base', sensitivity: 'base',
}); });
const sortedList = items const sortedList = items
.filter((item) => contains(item.name, _q) || contains(item.instrument, _q)) .filter((item) => includes(item.name, _q) || includes(item.instrument, _q))
.sort( .sort(
(a, b) => (a, b) =>
formatter.compare(a.name, b.name) || formatter.compare(a.instrument, b.instrument) formatter.compare(a.name, b.name) || formatter.compare(a.instrument, b.instrument)

View File

@ -0,0 +1,54 @@
import { renderHook } from '@testing-library/react-hooks';
import { useCollator } from '../useCollator';
describe('useCollator', () => {
it('should return the Intl.Collator class', () => {
const { result } = renderHook(() => useCollator('en'));
expect(result.current).toBeInstanceOf(Intl.Collator);
});
it('should pass options to the Intl.Collator class if I pass them to the hook', () => {
const { result } = renderHook(() => useCollator('en', { sensitivity: 'base' }));
expect(result.current.resolvedOptions().sensitivity).toBe('base');
});
it('should return me a new Intl.Collator if I pass a new locale or options object', () => {
const { result, rerender } = renderHook(({ locale, options }) => useCollator(locale, options), {
initialProps: {
locale: 'en',
options: { sensitivity: 'base' },
},
});
const first = result.current;
rerender({
locale: 'en',
options: { sensitivity: 'accent' },
});
const second = result.current;
expect(second).not.toBe(first);
rerender({
locale: 'fr',
options: { sensitivity: 'accent' },
});
const third = result.current;
expect(third).not.toBe(second);
expect(third).not.toBe(first);
rerender({
locale: 'en',
options: { sensitivity: 'base' },
});
expect(result.current).toBe(first);
});
});

View File

@ -0,0 +1,73 @@
import { renderHook } from '@testing-library/react-hooks';
import { useFilter } from '../useFilter';
describe('useFilter', () => {
it("should return an object with the properties 'startsWith', 'endsWith, & 'includes'", () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current).toHaveProperty('startsWith');
expect(result.current).toHaveProperty('endsWith');
expect(result.current).toHaveProperty('includes');
});
describe('startsWith', () => {
it('should return true if the substring is empty', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.startsWith('foo', '')).toBe(true);
});
it('should return true if the string starts with the substring', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.startsWith('foo', 'f')).toBe(true);
});
it('should return false if the string does not start with the substring', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.startsWith('foo', 'o')).toBe(false);
});
});
describe('endsWith', () => {
it('should return true if the substring is empty', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.endsWith('foo', '')).toBe(true);
});
it('should return true if the string ends with the substring', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.endsWith('foo', 'o')).toBe(true);
});
it('should return false if the string does not end with the substring', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.endsWith('foo', 'f')).toBe(false);
});
});
describe('includes', () => {
it('should return true if the substring is empty', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.includes('foo', '')).toBe(true);
});
it('should return true if the string includes the substring', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.includes('foo', 'o')).toBe(true);
});
it('should return false if the string does not include the substring', () => {
const { result } = renderHook(() => useFilter('en'));
expect(result.current.includes('foo', 'b')).toBe(false);
});
});
});

View File

@ -8,7 +8,7 @@ import { useCollator } from './useCollator';
* @typedef {Object} Filter * @typedef {Object} Filter
* @property {(string: string, substring: string) => boolean} startsWith Returns whether a string starts with a given substring. * @property {(string: string, substring: string) => boolean} startsWith Returns whether a string starts with a given substring.
* @property {(string: string, substring: string) => boolean} endsWith Returns whether a string ends with a given substring. * @property {(string: string, substring: string) => boolean} endsWith Returns whether a string ends with a given substring.
* @property {(string: string, substring: string) => boolean} contains Returns whether a string contains a given substring. * @property {(string: string, substring: string) => boolean} includes Returns whether a string contains a given substring.
*/ */
/** /**
@ -45,7 +45,7 @@ export function useFilter(locale, options) {
return collator.compare(string.slice(-substring.length), substring) === 0; return collator.compare(string.slice(-substring.length), substring) === 0;
}, },
contains(string, substring) { includes(string, substring) {
if (substring.length === 0) { if (substring.length === 0) {
return true; return true;
} }

View File

@ -79,7 +79,7 @@ const RoleListPage = () => {
enabled: canRead, enabled: canRead,
}); });
const { contains } = useFilter(locale, { const { includes } = useFilter(locale, {
sensitivity: 'base', sensitivity: 'base',
}); });
@ -131,7 +131,7 @@ const RoleListPage = () => {
}; };
const sortedRoles = (roles || []) const sortedRoles = (roles || [])
.filter((role) => contains(role.name, _q) || contains(role.description, _q)) .filter((role) => includes(role.name, _q) || includes(role.description, _q))
.sort( .sort(
(a, b) => formatter.compare(a.name, b.name) || formatter.compare(a.description, b.description) (a, b) => formatter.compare(a.name, b.name) || formatter.compare(a.description, b.description)
); );

View File

@ -7046,7 +7046,6 @@ __metadata:
markdown-it-mark: ^3.0.1 markdown-it-mark: ^3.0.1
markdown-it-sub: ^1.0.0 markdown-it-sub: ^1.0.0
markdown-it-sup: 1.0.0 markdown-it-sup: 1.0.0
match-sorter: ^4.0.2
mini-css-extract-plugin: 2.7.2 mini-css-extract-plugin: 2.7.2
msw: 1.0.1 msw: 1.0.1
node-schedule: 2.1.0 node-schedule: 2.1.0
@ -7264,7 +7263,6 @@ __metadata:
history: ^4.9.0 history: ^4.9.0
immer: 9.0.19 immer: 9.0.19
lodash: 4.17.21 lodash: 4.17.21
match-sorter: ^4.0.2
prop-types: ^15.7.2 prop-types: ^15.7.2
qs: 6.11.1 qs: 6.11.1
react: ^17.0.2 react: ^17.0.2
@ -7383,7 +7381,6 @@ __metadata:
history: ^4.9.0 history: ^4.9.0
immer: 9.0.19 immer: 9.0.19
lodash: 4.17.21 lodash: 4.17.21
match-sorter: ^4.0.2
pluralize: ^8.0.0 pluralize: ^8.0.0
prop-types: ^15.7.2 prop-types: ^15.7.2
qs: 6.11.1 qs: 6.11.1
@ -7635,7 +7632,6 @@ __metadata:
koa: ^2.13.4 koa: ^2.13.4
koa2-ratelimit: ^1.1.2 koa2-ratelimit: ^1.1.2
lodash: 4.17.21 lodash: 4.17.21
match-sorter: ^4.0.2
msw: 1.0.1 msw: 1.0.1
prop-types: ^15.7.2 prop-types: ^15.7.2
purest: 4.0.2 purest: 4.0.2
@ -22250,16 +22246,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"match-sorter@npm:^4.0.2":
version: 4.2.1
resolution: "match-sorter@npm:4.2.1"
dependencies:
"@babel/runtime": ^7.10.5
remove-accents: 0.4.2
checksum: 7f3cd8f84cdb4567b7a81f66a095e418044f318f41b6c8a1640730c99e370af9e5054f5a1ed2dfa1287a23101d75a105da63e4d95e8ac2f7061a8f8b32367c7d
languageName: node
linkType: hard
"match-sorter@npm:^6.0.2": "match-sorter@npm:^6.0.2":
version: 6.3.1 version: 6.3.1
resolution: "match-sorter@npm:6.3.1" resolution: "match-sorter@npm:6.3.1"