Merge pull request #14589 from strapi/market-sort-filters/filters

Add filters popover on marketplace
This commit is contained in:
Rémi de Juvigny 2022-10-11 16:56:17 +02:00 committed by GitHub
commit cb41fde5f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 979 additions and 412 deletions

View File

@ -1,11 +1,11 @@
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { useNotification } from '@strapi/helper-plugin'; import { useNotification } from '@strapi/helper-plugin';
import { fetchMarketplacePlugins } from './utils/api'; import { fetchMarketplaceProviders } from './utils/api';
const useFetchMarketplaceProviders = (notifyLoad) => { const useFetchMarketplaceProviders = (notifyLoad) => {
const toggleNotification = useNotification(); const toggleNotification = useNotification();
return useQuery('list-marketplace-providers', () => fetchMarketplacePlugins(), { return useQuery('list-marketplace-providers', () => fetchMarketplaceProviders(), {
onSuccess() { onSuccess() {
if (notifyLoad) { if (notifyLoad) {
notifyLoad(); notifyLoad();

View File

@ -2,10 +2,10 @@ import axios from 'axios';
const MARKETPLACE_API_URL = 'https://market-api.strapi.io'; const MARKETPLACE_API_URL = 'https://market-api.strapi.io';
const fetchMarketplacePlugins = async () => { const fetchMarketplaceProviders = async () => {
const { data } = await axios.get(`${MARKETPLACE_API_URL}/providers`); const { data } = await axios.get(`${MARKETPLACE_API_URL}/providers`);
return data; return data;
}; };
export { fetchMarketplacePlugins }; export { fetchMarketplaceProviders };

View File

@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Select, Option } from '@strapi/design-system/Select';
const FilterSelect = ({ message, value, onChange, possibleFilters, onClear, customizeContent }) => {
return (
<Select
aria-label={message}
placeholder={message}
size="M"
onChange={onChange}
onClear={onClear}
value={value}
customizeContent={customizeContent}
multi
>
{Object.entries(possibleFilters).map(([filterName, count]) => {
return (
<Option key={filterName} value={filterName}>
{filterName} ({count})
</Option>
);
})}
</Select>
);
};
FilterSelect.propTypes = {
message: PropTypes.string.isRequired,
value: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
possibleFilters: PropTypes.object.isRequired,
onClear: PropTypes.func.isRequired,
customizeContent: PropTypes.func.isRequired,
};
export default FilterSelect;

View File

@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Box } from '@strapi/design-system/Box';
import { Popover } from '@strapi/design-system/Popover';
import { Stack } from '@strapi/design-system/Stack';
import { FocusTrap } from '@strapi/design-system/FocusTrap';
import { useIntl } from 'react-intl';
import FilterSelect from './FilterSelect';
const FiltersPopover = ({
source,
onToggle,
query,
setQuery,
npmPackageType,
possibleCategories,
possibleCollections,
}) => {
const { formatMessage } = useIntl();
return (
<Popover source={source} padding={3} spacing={4} onBlur={() => {}}>
<FocusTrap onEscape={onToggle}>
<Stack spacing={1}>
<Box>
<FilterSelect
message={formatMessage({
id: 'admin.pages.MarketPlacePage.filters.collections',
defaultMessage: 'Collections',
})}
value={query?.collections || []}
onChange={(newCollections) => setQuery({ collections: newCollections })}
onClear={() => setQuery({ collections: [] }, 'remove')}
possibleFilters={possibleCollections}
customizeContent={(values) =>
formatMessage(
{
id: 'admin.pages.MarketPlacePage.filters.collectionsSelected',
defaultMessage:
'{count, plural, =0 {No collections} one {# collection} other {# collections}} selected',
},
{ count: values.length }
)
}
/>
</Box>
{npmPackageType === 'plugin' && (
<Box>
<FilterSelect
message={formatMessage({
id: 'admin.pages.MarketPlacePage.filters.categories',
defaultMessage: 'Categories',
})}
value={query?.categories || []}
onChange={(newCategories) => setQuery({ categories: newCategories })}
onClear={() => setQuery({ categories: [] }, 'remove')}
possibleFilters={possibleCategories}
customizeContent={(values) =>
formatMessage(
{
id: 'admin.pages.MarketPlacePage.filters.categoriesSelected',
defaultMessage:
'{count, plural, =0 {No categories} one {# category} other {# categories}} selected',
},
{ count: values.length }
)
}
name="categories"
/>
</Box>
)}
</Stack>
</FocusTrap>
</Popover>
);
};
FiltersPopover.propTypes = {
onToggle: PropTypes.func.isRequired,
source: PropTypes.shape({ current: PropTypes.instanceOf(Element) }).isRequired,
query: PropTypes.object.isRequired,
setQuery: PropTypes.func.isRequired,
npmPackageType: PropTypes.oneOf(['plugin', 'provider']).isRequired,
possibleCollections: PropTypes.object.isRequired,
possibleCategories: PropTypes.object.isRequired,
};
export default FiltersPopover;

View File

@ -0,0 +1,102 @@
import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from 'react-intl';
import { Box } from '@strapi/design-system/Box';
import { Button } from '@strapi/design-system/Button';
import { Tag } from '@strapi/design-system/Tag';
import Cross from '@strapi/icons/Cross';
import Filter from '@strapi/icons/Filter';
import FiltersPopover from './FiltersPopover';
const FilterTag = ({ name, handleRemove }) => {
return (
<Box padding={1}>
<Tag icon={<Cross />} onClick={handleRemove}>
{name}
</Tag>
</Box>
);
};
const NpmPackagesFilters = ({
possibleCollections,
possibleCategories,
npmPackageType,
query,
setQuery,
}) => {
const [isVisible, setIsVisible] = useState(false);
const buttonRef = useRef();
const { formatMessage } = useIntl();
const handleToggle = () => setIsVisible((prev) => !prev);
return (
<>
<Box paddingTop={1} paddingBottom={1}>
<Button
variant="tertiary"
ref={buttonRef}
startIcon={<Filter />}
onClick={handleToggle}
size="S"
>
{formatMessage({ id: 'app.utils.filters', defaultMessage: 'Filters' })}
</Button>
{isVisible && (
<FiltersPopover
onToggle={handleToggle}
source={buttonRef}
query={query}
setQuery={setQuery}
possibleCollections={possibleCollections}
possibleCategories={possibleCategories}
npmPackageType={npmPackageType}
/>
)}
</Box>
{query.collections?.map((collection) => (
<FilterTag
name={collection}
key={collection}
handleRemove={() =>
setQuery({
collections: query.collections.filter(
(previousCollection) => previousCollection !== collection
),
})
}
/>
))}
{npmPackageType === 'plugin' &&
query.categories?.map((category) => (
<FilterTag
name={category}
key={category}
handleRemove={() =>
setQuery({
categories: query.categories.filter(
(previousCategory) => previousCategory !== category
),
})
}
/>
))}
</>
);
};
FilterTag.propTypes = {
name: PropTypes.string.isRequired,
handleRemove: PropTypes.func.isRequired,
};
NpmPackagesFilters.propTypes = {
npmPackageType: PropTypes.oneOf(['plugin', 'provider']).isRequired,
possibleCollections: PropTypes.object.isRequired,
possibleCategories: PropTypes.object.isRequired,
query: PropTypes.object.isRequired,
setQuery: PropTypes.func.isRequired,
};
export default NpmPackagesFilters;

View File

@ -10,6 +10,7 @@ import {
LoadingIndicatorPage, LoadingIndicatorPage,
useNotification, useNotification,
useAppInfos, useAppInfos,
useQueryParams,
} from '@strapi/helper-plugin'; } from '@strapi/helper-plugin';
import { Layout, ContentLayout } from '@strapi/design-system/Layout'; import { Layout, ContentLayout } from '@strapi/design-system/Layout';
import { Main } from '@strapi/design-system/Main'; import { Main } from '@strapi/design-system/Main';
@ -29,6 +30,7 @@ import offlineCloud from '../../assets/images/icon_offline-cloud.svg';
import useNavigatorOnLine from '../../hooks/useNavigatorOnLine'; import useNavigatorOnLine from '../../hooks/useNavigatorOnLine';
import MissingPluginBanner from './components/MissingPluginBanner'; import MissingPluginBanner from './components/MissingPluginBanner';
import NpmPackagesGrid from './components/NpmPackagesGrid'; import NpmPackagesGrid from './components/NpmPackagesGrid';
import NpmPackagesFilters from './components/NpmPackagesFilters';
const matchSearch = (npmPackages, search) => { const matchSearch = (npmPackages, search) => {
return matchSorter(npmPackages, search, { return matchSorter(npmPackages, search, {
@ -49,9 +51,10 @@ const MarketPlacePage = () => {
const trackUsageRef = useRef(trackUsage); const trackUsageRef = useRef(trackUsage);
const toggleNotification = useNotification(); const toggleNotification = useNotification();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [npmPackageType, setNpmPackageType] = useState('plugin');
const { autoReload: isInDevelopmentMode, dependencies, useYarn } = useAppInfos(); const { autoReload: isInDevelopmentMode, dependencies, useYarn } = useAppInfos();
const isOnline = useNavigatorOnLine(); const isOnline = useNavigatorOnLine();
const [{ query }, setQuery] = useQueryParams();
const npmPackageType = query?.npmPackageType || 'plugin';
useFocusWhenNavigate(); useFocusWhenNavigate();
@ -170,12 +173,23 @@ const MarketPlacePage = () => {
const handleTabChange = (selected) => { const handleTabChange = (selected) => {
const packageType = selected === 0 ? 'plugin' : 'provider'; const packageType = selected === 0 ? 'plugin' : 'provider';
setNpmPackageType(packageType); setQuery({
// Save new tab in the query params
npmPackageType: packageType,
// Clear filters
collections: [],
categories: [],
});
}; };
// Check if plugins and providers are installed already // Check if plugins and providers are installed already
const installedPackageNames = Object.keys(dependencies); const installedPackageNames = Object.keys(dependencies);
const possibleCollections =
npmPackageType === 'plugin'
? marketplacePluginsResponse.meta.collections
: marketplaceProvidersResponse.meta.collections;
return ( return (
<Layout> <Layout>
<Main> <Main>
@ -215,6 +229,7 @@ const MarketPlacePage = () => {
})} })}
id="tabs" id="tabs"
variant="simple" variant="simple"
initialSelectedTabIndex={['plugin', 'provider'].indexOf(npmPackageType)}
onTabChange={handleTabChange} onTabChange={handleTabChange}
> >
<Box paddingBottom={4}> <Box paddingBottom={4}>
@ -235,6 +250,16 @@ const MarketPlacePage = () => {
</Tab> </Tab>
</Tabs> </Tabs>
</Box> </Box>
<Flex paddingBottom={4} gap={2}>
<NpmPackagesFilters
npmPackageType={npmPackageType}
possibleCollections={possibleCollections}
possibleCategories={marketplacePluginsResponse.meta.categories}
query={query || {}}
setQuery={setQuery}
/>
</Flex>
<TabPanels> <TabPanels>
{/* Plugins panel */} {/* Plugins panel */}
<TabPanel> <TabPanel>

View File

@ -11,6 +11,8 @@ import {
} from '@testing-library/react'; } from '@testing-library/react';
import { IntlProvider } from 'react-intl'; import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query'; import { QueryClient, QueryClientProvider } from 'react-query';
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { ThemeProvider, lightTheme } from '@strapi/design-system'; import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { useTracking, useAppInfos } from '@strapi/helper-plugin'; import { useTracking, useAppInfos } from '@strapi/helper-plugin';
import useNavigatorOnLine from '../../../hooks/useNavigatorOnLine'; import useNavigatorOnLine from '../../../hooks/useNavigatorOnLine';
@ -47,11 +49,15 @@ const client = new QueryClient({
}, },
}); });
const history = createMemoryHistory();
const App = ( const App = (
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<IntlProvider locale="en" messages={{}} textComponent="span"> <IntlProvider locale="en" messages={{}} textComponent="span">
<ThemeProvider theme={lightTheme}> <ThemeProvider theme={lightTheme}>
<MarketPlacePage /> <Router history={history}>
<MarketPlacePage />
</Router>
</ThemeProvider> </ThemeProvider>
</IntlProvider> </IntlProvider>
</QueryClientProvider> </QueryClientProvider>
@ -65,19 +71,47 @@ describe('Marketplace page', () => {
afterAll(() => server.close()); afterAll(() => server.close());
it('renders and matches the plugin tab snapshot', async () => { it('renders and matches the plugin tab snapshot', async () => {
// Check snapshot
const { container, getByTestId, getByRole } = render(App); const { container, getByTestId, getByRole } = render(App);
await waitForElementToBeRemoved(() => getByTestId('loader')); await waitForElementToBeRemoved(() => getByTestId('loader'));
await waitFor(() => expect(getByRole('heading', { name: /marketplace/i })).toBeInTheDocument()); await waitFor(() => expect(getByRole('heading', { name: /marketplace/i })).toBeInTheDocument());
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
// Make sure it defaults to the plugins tab
const button = screen.getByRole('tab', { selected: true });
const pluginsTabActive = getByText(button, /plugins/i);
const tabPanel = screen.getByRole('tabpanel');
const pluginCardText = getByText(tabPanel, 'Comments');
const providerCardText = queryByText(tabPanel, 'Cloudinary');
const submitPluginText = queryByText(container, 'Submit plugin');
expect(pluginsTabActive).not.toBe(null);
expect(pluginCardText).toBeVisible();
expect(submitPluginText).toBeVisible();
expect(providerCardText).toEqual(null);
}); });
it('renders and matches the provider tab snapshot', async () => { it('renders and matches the provider tab snapshot', () => {
// Make sure it switches to the providers tab
const { container, getByRole } = render(App); const { container, getByRole } = render(App);
await waitFor(() => expect(getByRole('heading', { name: /marketplace/i })).toBeInTheDocument()); const providersTab = getByRole('tab', { name: /providers/i });
const providersTab = screen.getByRole('tab', { selected: false });
fireEvent.click(providersTab); fireEvent.click(providersTab);
const button = getByRole('tab', { selected: true });
const providersTabActive = getByText(button, /Providers/i);
const tabPanel = getByRole('tabpanel');
const providerCardText = getByText(tabPanel, 'Cloudinary');
const pluginCardText = queryByText(tabPanel, 'Comments');
const submitProviderText = queryByText(container, 'Submit provider');
expect(providersTabActive).not.toBe(null);
expect(providerCardText).toBeVisible();
expect(submitProviderText).toBeVisible();
expect(pluginCardText).toEqual(null);
// Check snapshot
expect(container.firstChild).toMatchSnapshot(); expect(container.firstChild).toMatchSnapshot();
}); });
@ -90,9 +124,12 @@ describe('Marketplace page', () => {
expect(trackUsage).toHaveBeenCalledTimes(1); expect(trackUsage).toHaveBeenCalledTimes(1);
}); });
it('should return plugin search results matching the query', async () => { it('should return plugin search results matching the query', () => {
const { container } = render(App); const { container } = render(App);
const input = await getByPlaceholderText(container, 'Search'); const pluginsTab = screen.getByRole('tab', { name: /plugins/i });
fireEvent.click(pluginsTab);
const input = getByPlaceholderText(container, 'Search');
fireEvent.change(input, { target: { value: 'comment' } }); fireEvent.change(input, { target: { value: 'comment' } });
const match = screen.getByText('Comments'); const match = screen.getByText('Comments');
const notMatch = screen.queryByText('Sentry'); const notMatch = screen.queryByText('Sentry');
@ -103,12 +140,12 @@ describe('Marketplace page', () => {
expect(provider).toEqual(null); expect(provider).toEqual(null);
}); });
it('should return provider search results matching the query', async () => { it('should return provider search results matching the query', () => {
const { container } = render(App); const { container } = render(App);
const providersTab = screen.getByRole('tab', { selected: false }); const providersTab = screen.getByRole('tab', { name: /providers/i });
fireEvent.click(providersTab); fireEvent.click(providersTab);
const input = await getByPlaceholderText(container, 'Search'); const input = getByPlaceholderText(container, 'Search');
fireEvent.change(input, { target: { value: 'cloudina' } }); fireEvent.change(input, { target: { value: 'cloudina' } });
const match = screen.getByText('Cloudinary'); const match = screen.getByText('Cloudinary');
const notMatch = screen.queryByText('Mailgun'); const notMatch = screen.queryByText('Mailgun');
@ -119,9 +156,9 @@ describe('Marketplace page', () => {
expect(plugin).toEqual(null); expect(plugin).toEqual(null);
}); });
it('should return empty plugin search results given a bad query', async () => { it('should return empty plugin search results given a bad query', () => {
const { container } = render(App); const { container } = render(App);
const input = await getByPlaceholderText(container, 'Search'); const input = getByPlaceholderText(container, 'Search');
const badQuery = 'asdf'; const badQuery = 'asdf';
fireEvent.change(input, { target: { value: badQuery } }); fireEvent.change(input, { target: { value: badQuery } });
const noResult = screen.getByText(`No result for "${badQuery}"`); const noResult = screen.getByText(`No result for "${badQuery}"`);
@ -129,11 +166,11 @@ describe('Marketplace page', () => {
expect(noResult).toBeVisible(); expect(noResult).toBeVisible();
}); });
it('should return empty provider search results given a bad query', async () => { it('should return empty provider search results given a bad query', () => {
const { container } = render(App); const { container } = render(App);
const providersTab = screen.getByRole('tab', { selected: false }); const providersTab = screen.getByRole('tab', { name: /providers/i });
fireEvent.click(providersTab); fireEvent.click(providersTab);
const input = await getByPlaceholderText(container, 'Search'); const input = getByPlaceholderText(container, 'Search');
const badQuery = 'asdf'; const badQuery = 'asdf';
fireEvent.change(input, { target: { value: badQuery } }); fireEvent.change(input, { target: { value: badQuery } });
const noResult = screen.getByText(`No result for "${badQuery}"`); const noResult = screen.getByText(`No result for "${badQuery}"`);
@ -141,6 +178,25 @@ describe('Marketplace page', () => {
expect(noResult).toBeVisible(); expect(noResult).toBeVisible();
}); });
it('shows filters popover on plugins and providers', () => {
render(App);
// Show collections and categories filters on plugins
const pluginsTab = screen.getByRole('tab', { name: /plugins/i });
fireEvent.click(pluginsTab);
const filtersButton = screen.getByRole('button', { name: /filters/i });
fireEvent.click(filtersButton);
screen.getByLabelText(/no collections selected/i);
screen.getByLabelText(/no categories selected/i);
fireEvent.click(filtersButton);
// Only show collections filters on providers
const providersTab = screen.getByRole('tab', { name: /providers/i });
fireEvent.click(providersTab);
fireEvent.click(filtersButton);
screen.getByLabelText(/no collections selected/i);
});
it('handles production environment', () => { it('handles production environment', () => {
// Simulate production environment // Simulate production environment
useAppInfos.mockImplementationOnce(() => ({ useAppInfos.mockImplementationOnce(() => ({
@ -181,41 +237,7 @@ describe('Marketplace page', () => {
expect(offlineText).toBeVisible(); expect(offlineText).toBeVisible();
}); });
it('defaults to plugins tab', async () => { it('shows the installed text for installed plugins', () => {
const { container } = render(App);
const button = screen.getByRole('tab', { selected: true });
const pluginsTabActive = await getByText(button, /Plugins/i);
const tabPanel = screen.getByRole('tabpanel');
const pluginCardText = await getByText(tabPanel, 'Comments');
const providerCardText = await queryByText(tabPanel, 'Cloudinary');
const submitPluginText = await queryByText(container, 'Submit plugin');
expect(pluginsTabActive).not.toBe(null);
expect(pluginCardText).toBeVisible();
expect(submitPluginText).toBeVisible();
expect(providerCardText).toEqual(null);
});
it('switches to providers tab', async () => {
const { container } = render(App);
const providersTab = screen.getByRole('tab', { selected: false });
fireEvent.click(providersTab);
const button = screen.getByRole('tab', { selected: true });
const providersTabActive = await getByText(button, /Providers/i);
const tabPanel = screen.getByRole('tabpanel');
const providerCardText = await getByText(tabPanel, 'Cloudinary');
const pluginCardText = await queryByText(tabPanel, 'Comments');
const submitProviderText = await queryByText(container, 'Submit provider');
expect(providersTabActive).not.toBe(null);
expect(providerCardText).toBeVisible();
expect(submitProviderText).toBeVisible();
expect(pluginCardText).toEqual(null);
});
it('shows the installed text for installed plugins', async () => {
render(App); render(App);
const pluginsTab = screen.getByRole('tab', { name: /plugins/i }); const pluginsTab = screen.getByRole('tab', { name: /plugins/i });
fireEvent.click(pluginsTab); fireEvent.click(pluginsTab);
@ -235,7 +257,7 @@ describe('Marketplace page', () => {
expect(notInstalledText).toBeVisible(); expect(notInstalledText).toBeVisible();
}); });
it('shows the installed text for installed providers', async () => { it('shows the installed text for installed providers', () => {
// Open providers tab // Open providers tab
render(App); render(App);
const providersTab = screen.getByRole('tab', { name: /providers/i }); const providersTab = screen.getByRole('tab', { name: /providers/i });

View File

@ -491,6 +491,19 @@ const handlers = [
}, },
}, },
], ],
meta: {
collections: {
'Made by official partners': 9,
'Made by Strapi': 13,
'Made by the community': 69,
Verified: 29,
},
categories: {
'Custom fields': 4,
Deployment: 2,
Monitoring: 1,
},
},
}) })
); );
}), }),
@ -903,6 +916,14 @@ const handlers = [
}, },
}, },
], ],
meta: {
collections: {
'Made by official partners': 0,
'Made by Strapi': 6,
'Made by the community': 2,
Verified: 6,
},
},
}) })
); );
}), }),

View File

@ -100,11 +100,11 @@
"Settings.apiTokens.duration.30-days": "30 days", "Settings.apiTokens.duration.30-days": "30 days",
"Settings.apiTokens.duration.90-days": "90 days", "Settings.apiTokens.duration.90-days": "90 days",
"Settings.apiTokens.duration.unlimited": "Unlimited", "Settings.apiTokens.duration.unlimited": "Unlimited",
"Settings.apiTokens.form.duration":"Token duration", "Settings.apiTokens.form.duration": "Token duration",
"Settings.apiTokens.form.type":"Token type", "Settings.apiTokens.form.type": "Token type",
"Settings.apiTokens.duration.expiration-date":"Expiration date", "Settings.apiTokens.duration.expiration-date": "Expiration date",
"Settings.apiTokens.createPage.permissions.title":"Permissions", "Settings.apiTokens.createPage.permissions.title": "Permissions",
"Settings.apiTokens.createPage.permissions.description":"Only actions bound by a route are listed below.", "Settings.apiTokens.createPage.permissions.description": "Only actions bound by a route are listed below.",
"Settings.apiTokens.RegenerateDialog.title": "Regenerate token", "Settings.apiTokens.RegenerateDialog.title": "Regenerate token",
"Settings.apiTokens.popUpWarning.message": "Are you sure you want to regenerate this token?", "Settings.apiTokens.popUpWarning.message": "Are you sure you want to regenerate this token?",
"Settings.apiTokens.Button.cancel": "Cancel", "Settings.apiTokens.Button.cancel": "Cancel",
@ -280,6 +280,10 @@
"admin.pages.MarketPlacePage.tab-group.label": "Plugins and Providers for Strapi", "admin.pages.MarketPlacePage.tab-group.label": "Plugins and Providers for Strapi",
"admin.pages.MarketPlacePage.missingPlugin.title": "Missing a plugin?", "admin.pages.MarketPlacePage.missingPlugin.title": "Missing a plugin?",
"admin.pages.MarketPlacePage.missingPlugin.description": "Tell us what plugin you are looking for and we'll let our community plugin developers know in case they are in search for inspiration!", "admin.pages.MarketPlacePage.missingPlugin.description": "Tell us what plugin you are looking for and we'll let our community plugin developers know in case they are in search for inspiration!",
"admin.pages.MarketPlacePage.filters.collections": "Collections",
"admin.pages.MarketPlacePage.filters.collectionsSelected": "{count, plural, =0 {No collections} one {# collection} other {# collections}} selected",
"admin.pages.MarketPlacePage.filters.categories": "Categories",
"admin.pages.MarketPlacePage.filters.categoriesSelected": "{count, plural, =0 {No categories} one {# category} other {# categories}} selected",
"anErrorOccurred": "Woops! Something went wrong. Please, try again.", "anErrorOccurred": "Woops! Something went wrong. Please, try again.",
"app.component.CopyToClipboard.label": "Copy to clipboard", "app.component.CopyToClipboard.label": "Copy to clipboard",
"app.component.search.label": "Search for {target}", "app.component.search.label": "Search for {target}",