diff --git a/packages/core/admin/admin/src/content-manager/pages/ListView/index.js b/packages/core/admin/admin/src/content-manager/pages/ListView/index.js index c94da96f89..10d445ba60 100644 --- a/packages/core/admin/admin/src/content-manager/pages/ListView/index.js +++ b/packages/core/admin/admin/src/content-manager/pages/ListView/index.js @@ -12,7 +12,7 @@ import { stringify } from 'qs'; import { NoPermissions, CheckPermissions, - Search, + SearchURLQuery, useFocusWhenNavigate, useQueryParams, useNotification, @@ -296,7 +296,7 @@ function ListView({ startActions={ <> {isSearchable && ( - { - { {canRead && ( - -import { ArgsTable, Meta } from '@storybook/addon-docs'; -import Search from './index'; - - - -# Search - -This component provides and input to search an array - -## Imports - -```js -import { Search } from '@strapi/helper-plugin'; -import { useIntl } from 'react-intl'; -``` - -## Usage - -```jsx -import { Search, useQueryParams } from '@strapi/helper-plugin'; -import matchSorter from 'match-sorter'; - -const HomePage = () => { - const [{ query }] = useQueryParams() - const { formatMessage } = useIntl(); - const _q = query?._q || '' - const items = [{name: 'Paul', instrument: 'bass'}, {name: 'George', instrument: 'guitar'}] - - const sortedList = matchSorter(items, _q, {keys: ['name', 'instrument']}) - const itemsList = sortedList?.length ? sortedList : items - - return ( - - {itemsList.map(item => ( -
-

{item.name}

-

{item.instrument}

-
- ))} - ) -}; -``` - - diff --git a/packages/core/helper-plugin/lib/src/components/Search/index.js b/packages/core/helper-plugin/lib/src/components/Search/index.js deleted file mode 100644 index 8e0e8ec88a..0000000000 --- a/packages/core/helper-plugin/lib/src/components/Search/index.js +++ /dev/null @@ -1,104 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from 'react-intl'; -import SearchIcon from '@strapi/icons/Search'; -import { Searchbar } from '@strapi/design-system/Searchbar'; -import { IconButton } from '@strapi/design-system/IconButton'; -import useQueryParams from '../../hooks/useQueryParams'; -import useTracking from '../../hooks/useTracking'; - -const Search = ({ label, trackedEvent }) => { - const wrapperRef = useRef(null); - const iconButtonRef = useRef(null); - const isMountedRef = useRef(false); - const [didSearch, setDidSearch] = useState(false); - - const [isOpen, setIsOpen] = useState(false); - const [{ query }, setQuery] = useQueryParams(); - const [value, setValue] = useState(query?._q || ''); - const { formatMessage } = useIntl(); - const { trackUsage } = useTracking(); - - const handleToggle = () => setIsOpen(prev => !prev); - - useEffect(() => { - if (isMountedRef.current) { - if (isOpen) { - wrapperRef.current.querySelector('input').focus(); - } else { - iconButtonRef.current.focus(); - } - } - - isMountedRef.current = true; - }, [isOpen]); - - useEffect(() => { - if (didSearch && trackedEvent) { - trackUsage(trackedEvent); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [didSearch, trackedEvent]); - - useEffect(() => { - const handler = setTimeout(() => { - if (!didSearch) { - return; - } - - if (value) { - setQuery({ _q: value, page: 1 }); - } else { - setDidSearch(false); - setQuery({ _q: '' }, 'remove'); - } - }, 300); - - return () => clearTimeout(handler); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); - - useEffect(() => { - if (value && !isOpen) { - setIsOpen(true); - } - }, [value, isOpen]); - - if (isOpen) { - return ( -
- { - setDidSearch(true); - setValue(value); - }} - value={value} - clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })} - onClear={() => { - setValue(''); - setIsOpen(false); - setDidSearch(false); - }} - > - {label} - -
- ); - } - - return ( - } label="Search" onClick={handleToggle} /> - ); -}; - -Search.defaultProps = { - trackedEvent: null, -}; - -Search.propTypes = { - label: PropTypes.string.isRequired, - trackedEvent: PropTypes.string, -}; - -export default Search; diff --git a/packages/core/helper-plugin/lib/src/components/SearchURLQuery/SearchURLQuery.stories.mdx b/packages/core/helper-plugin/lib/src/components/SearchURLQuery/SearchURLQuery.stories.mdx new file mode 100644 index 0000000000..8c689995f6 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/components/SearchURLQuery/SearchURLQuery.stories.mdx @@ -0,0 +1,82 @@ + + +import { ArgsTable, Meta, Canvas, Story } from '@storybook/addon-docs'; +import { Stack } from '@strapi/design-system/Stack'; +import matchSorter from 'match-sorter'; +import useQueryParams from '../../hooks/useQueryParams'; +import SearchURLQuery from './index'; + + + +# SearchURLQuery + +This component provides and input to search an array + +## Imports + +```js +import { SearchURLQuery } from '@strapi/helper-plugin'; +import { useIntl } from 'react-intl'; +``` + +## Usage + +```jsx +import { SearchURLQuery, useQueryParams } from '@strapi/helper-plugin'; +import matchSorter from 'match-sorter'; + +const HomePage = () => { + const [{ query }] = useQueryParams() + const { formatMessage } = useIntl(); + const _q = query?._q || '' + const items = [{name: 'Paul', instrument: 'bass'}, {name: 'George', instrument: 'guitar'}] + + const sortedList = matchSorter(items, _q, {keys: ['name', 'instrument']}) + const itemsList = sortedList?.length ? sortedList : items + + return ( + + {itemsList.map(item => ( +
+

{item.name}

+

{item.instrument}

+
+ ))} + ) +}; +``` + +## Base + + + + {() => { + const [{ query }] = useQueryParams() + const _q = query?._q || '' + const items = [{name: 'Paul', instrument: 'bass'}, {name: 'George', instrument: 'guitar'}] + const sortedList = matchSorter(items, _q, {keys: ['name', 'instrument']}) + const itemsList = sortedList?.length ? sortedList : items; + return ( + + + {itemsList.map((item, i) => ( +
+

{item.name}

+

{item.instrument}

+
+ ))} +
+ ); + }} +
+
+ + diff --git a/packages/core/helper-plugin/lib/src/components/SearchURLQuery/index.js b/packages/core/helper-plugin/lib/src/components/SearchURLQuery/index.js new file mode 100644 index 0000000000..d0d1f496fe --- /dev/null +++ b/packages/core/helper-plugin/lib/src/components/SearchURLQuery/index.js @@ -0,0 +1,82 @@ +import React, { useLayoutEffect, useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from 'react-intl'; +import SearchIcon from '@strapi/icons/Search'; +import { Searchbar, SearchForm } from '@strapi/design-system/Searchbar'; +import { IconButton } from '@strapi/design-system/IconButton'; +import useQueryParams from '../../hooks/useQueryParams'; +import useTracking from '../../hooks/useTracking'; + +const SearchURLQuery = ({ label, trackedEvent }) => { + const wrapperRef = useRef(null); + const iconButtonRef = useRef(null); + + const [{ query }, setQuery] = useQueryParams(); + const [value, setValue] = useState(query?._q || ''); + const [isOpen, setIsOpen] = useState(!!value); + const { formatMessage } = useIntl(); + const { trackUsage } = useTracking(); + + const handleToggle = () => setIsOpen(prev => !prev); + + useLayoutEffect(() => { + if (isOpen) { + setTimeout(() => { + wrapperRef.current.querySelector('input').focus(); + }, 0); + } + }, [isOpen]); + + const handleClear = () => { + setValue(''); + setQuery({ _q: '' }, 'remove'); + }; + + const handleSubmit = e => { + e.preventDefault(); + + if (value) { + if (trackedEvent) { + trackUsage(trackedEvent); + } + setQuery({ _q: value, page: 1 }); + } else { + handleToggle(); + setQuery({ _q: '' }, 'remove'); + } + }; + + if (isOpen) { + return ( +
+ + setValue(value)} + value={value} + clearLabel={formatMessage({ id: 'clearLabel', defaultMessage: 'Clear' })} + onClear={handleClear} + size="S" + > + {label} + + +
+ ); + } + + return ( + } label="Search" onClick={handleToggle} /> + ); +}; + +SearchURLQuery.defaultProps = { + trackedEvent: null, +}; + +SearchURLQuery.propTypes = { + label: PropTypes.string.isRequired, + trackedEvent: PropTypes.string, +}; + +export default SearchURLQuery; diff --git a/packages/core/helper-plugin/lib/src/components/SearchURLQuery/tests/index.test.js b/packages/core/helper-plugin/lib/src/components/SearchURLQuery/tests/index.test.js new file mode 100644 index 0000000000..5fb2396748 --- /dev/null +++ b/packages/core/helper-plugin/lib/src/components/SearchURLQuery/tests/index.test.js @@ -0,0 +1,495 @@ +/** + * + * Tests for SearchURLQuery + * + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { ThemeProvider, lightTheme } from '@strapi/design-system'; +import SearchURLQuery from '../index'; + +const trackUsage = jest.fn(); +jest.mock('../../../hooks/useTracking', () => () => ({ + trackUsage, +})); + +const makeApp = (history, trackedEvent) => ( + + + + + + + +); + +describe('', () => { + it('renders and matches the snapshot', () => { + const history = createMemoryHistory(); + const { container } = render(makeApp(history)); + + expect(container).toMatchInlineSnapshot(` + .c2 { + border: 0; + -webkit-clip: rect(0 0 0 0); + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + } + + .c0 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + cursor: pointer; + padding: 8px; + border-radius: 4px; + background: #ffffff; + border: 1px solid #dcdce4; + position: relative; + outline: none; + } + + .c0 svg { + height: 12px; + width: 12px; + } + + .c0 svg > g, + .c0 svg path { + fill: #ffffff; + } + + .c0[aria-disabled='true'] { + pointer-events: none; + } + + .c0:after { + -webkit-transition-property: all; + transition-property: all; + -webkit-transition-duration: 0.2s; + transition-duration: 0.2s; + border-radius: 8px; + content: ''; + position: absolute; + top: -4px; + bottom: -4px; + left: -4px; + right: -4px; + border: 2px solid transparent; + } + + .c0:focus-visible { + outline: none; + } + + .c0:focus-visible:after { + border-radius: 8px; + content: ''; + position: absolute; + top: -5px; + bottom: -5px; + left: -5px; + right: -5px; + border: 2px solid #4945ff; + } + + .c1 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + height: 2rem; + width: 2rem; + } + + .c1 svg > g, + .c1 svg path { + fill: #8e8ea9; + } + + .c1:hover svg > g, + .c1:hover svg path { + fill: #666687; + } + + .c1:active svg > g, + .c1:active svg path { + fill: #a5a5ba; + } + + .c1[aria-disabled='true'] { + background-color: #eaeaef; + } + + .c1[aria-disabled='true'] svg path { + fill: #666687; + } + +
+ + + +
+

+

+

+
+ `); + }); + + it('should toggle searchbar form and searchbar', async () => { + const history = createMemoryHistory(); + const { container } = render(makeApp(history)); + + fireEvent.click(container.querySelector('button[type="button"]')); + + expect(container).toMatchInlineSnapshot(` + .c11 { + border: 0; + -webkit-clip: rect(0 0 0 0); + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + } + + .c2 { + font-weight: 500; + font-size: 0.75rem; + line-height: 1.33; + color: #32324d; + } + + .c6 { + padding-right: 8px; + padding-left: 12px; + } + + .c4 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + } + + .c8 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + } + + .c10 { + border: none; + border-radius: 4px; + padding-left: 0; + padding-right: 16px; + color: #32324d; + font-weight: 400; + font-size: 0.875rem; + display: block; + width: 100%; + } + + .c10::-webkit-input-placeholder { + color: #8e8ea9; + opacity: 1; + } + + .c10::-moz-placeholder { + color: #8e8ea9; + opacity: 1; + } + + .c10:-ms-input-placeholder { + color: #8e8ea9; + opacity: 1; + } + + .c10::placeholder { + color: #8e8ea9; + opacity: 1; + } + + .c10[aria-disabled='true'] { + background: inherit; + color: inherit; + } + + .c10:focus { + outline: none; + box-shadow: none; + } + + .c5 { + border: 1px solid #dcdce4; + border-radius: 4px; + background: #ffffff; + height: 2rem; + outline: none; + box-shadow: 0; + -webkit-transition-property: border-color,box-shadow,fill; + transition-property: border-color,box-shadow,fill; + -webkit-transition-duration: 0.2s; + transition-duration: 0.2s; + } + + .c5:focus-within { + border: 1px solid #4945ff; + box-shadow: #4945ff 0px 0px 0px 2px; + } + + .c1 { + border: 0; + -webkit-clip: rect(0 0 0 0); + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + } + + .c9 { + font-size: 0.8rem; + } + + .c9 svg path { + fill: #32324d; + } + + .c0 { + border-radius: 4px; + box-shadow: 0px 1px 4px rgba(33,33,52,0.1); + outline: none; + box-shadow: 0; + -webkit-transition-property: border-color,box-shadow,fill; + transition-property: border-color,box-shadow,fill; + -webkit-transition-duration: 0.2s; + transition-duration: 0.2s; + } + + .c0:focus-within .c7 svg path { + fill: #4945ff; + } + + .c0 .c3 { + border: 1px solid transparent; + } + + .c0 .c3:focus-within { + border: 1px solid #4945ff; + box-shadow: #4945ff 0px 0px 0px 2px; + } + +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+

+

+

+
+ `); + }); + + it('should push value to query params', async () => { + const history = createMemoryHistory(); + const { container } = render(makeApp(history)); + + fireEvent.click(container.querySelector('button[type="button"]')); + + const input = container.querySelector('input[name="search"]'); + fireEvent.change(input, { target: { value: 'michka' } }); + fireEvent.submit(input); + + const urlSearchQuery = history.location.search; + expect(urlSearchQuery).toEqual('?_q=michka&page=1'); + }); + + it('should clear value and update query params', async () => { + const history = createMemoryHistory(); + const { container } = render(makeApp(history)); + + fireEvent.click(container.querySelector('button[type="button"]')); + + const input = container.querySelector('input[name="search"]'); + fireEvent.change(input, { target: { value: 'michka' } }); + fireEvent.submit(input); + + const urlSearchQuery = history.location.search; + expect(urlSearchQuery).toEqual('?_q=michka&page=1'); + + fireEvent.click(container.querySelector('button[aria-label="Clear"]')); + + expect(input.value).toEqual(''); + + const clearedUrlSearchQuery = history.location.search; + expect(clearedUrlSearchQuery).toEqual('?page=1'); + }); + + it.only('should call trackUsage with trackedEvent props when submit', async () => { + const history = createMemoryHistory(); + const { container } = render(makeApp(history, 'thisEvent')); + + fireEvent.click(container.querySelector('button[type="button"]')); + + const input = container.querySelector('input[name="search"]'); + fireEvent.change(input, { target: { value: 'michka' } }); + fireEvent.submit(input); + + expect(trackUsage.mock.calls.length).toBe(1); + }); +}); diff --git a/packages/core/helper-plugin/lib/src/index.js b/packages/core/helper-plugin/lib/src/index.js index 24a2fda8db..72bb48ee3f 100644 --- a/packages/core/helper-plugin/lib/src/index.js +++ b/packages/core/helper-plugin/lib/src/index.js @@ -182,7 +182,7 @@ export * from './components/InjectionZone'; export { default as LoadingIndicatorPage } from './components/LoadingIndicatorPage'; export { default as NotAllowedInput } from './components/NotAllowedInput'; export { default as SettingsPageTitle } from './components/SettingsPageTitle'; -export { default as Search } from './components/Search'; +export { default as SearchURLQuery } from './components/SearchURLQuery'; export { default as Status } from './components/Status'; export { default as FilterListURLQuery } from './components/FilterListURLQuery'; export { default as FilterPopoverURLQuery } from './components/FilterPopoverURLQuery'; diff --git a/packages/core/upload/admin/src/pages/App/MediaLibrary.js b/packages/core/upload/admin/src/pages/App/MediaLibrary.js index 0be1f82ac0..c433dfe34c 100644 --- a/packages/core/upload/admin/src/pages/App/MediaLibrary.js +++ b/packages/core/upload/admin/src/pages/App/MediaLibrary.js @@ -7,7 +7,7 @@ import { NoPermissions, NoMedia, AnErrorOccurred, - Search, + SearchURLQuery, useSelectionState, useQueryParams, } from '@strapi/helper-plugin'; @@ -124,7 +124,7 @@ export const MediaLibrary = () => { } endActions={ - { })} primaryAction={ -

List of roles

{ role="alert" >
Loading content.

You don't have any roles yet.