Feat: OSS connections page state filter and update all added (#100688)

* feat: OSS connections page state filter and update all added

* fix: use combobox instead of select

* fix: show no updates available text

* ref: extract update all button to a component
This commit is contained in:
Syerikjan Kh
2025-02-21 10:16:45 -05:00
committed by GitHub
parent 66bad69e00
commit de0682521d
10 changed files with 348 additions and 71 deletions
+1 -2
View File
@@ -3144,8 +3144,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
],
"public/app/features/connections/tabs/ConnectData/ConnectData.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@@ -1,12 +1,41 @@
import { useState } from 'react';
import { PluginType } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page';
import UpdateAllButton from 'app/features/plugins/admin/components/UpdateAllButton';
import UpdateAllModal from 'app/features/plugins/admin/components/UpdateAllModal';
import { useGetUpdatable } from 'app/features/plugins/admin/state/hooks';
import { AddNewConnection } from '../tabs/ConnectData';
export function AddNewConnectionPage() {
const { isLoading: areUpdatesLoading, updatablePlugins } = useGetUpdatable();
const updatableDSPlugins = updatablePlugins.filter((plugin) => plugin.type === PluginType.datasource);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const disableUpdateAllButton = updatableDSPlugins.length <= 0 || areUpdatesLoading;
const onUpdateAll = () => {
setShowUpdateModal(true);
};
const updateAllButton = (
<UpdateAllButton
disabled={disableUpdateAllButton}
onUpdateAll={onUpdateAll}
updatablePluginsLength={updatableDSPlugins.length}
/>
);
return (
<Page navId={'connections-add-new-connection'}>
<Page navId={'connections-add-new-connection'} actions={updateAllButton}>
<Page.Contents>
<AddNewConnection />
<UpdateAllModal
isOpen={showUpdateModal}
isLoading={areUpdatesLoading}
onDismiss={() => setShowUpdateModal(false)}
plugins={updatableDSPlugins}
/>
</Page.Contents>
</Page>
);
@@ -2,8 +2,19 @@ import { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, Grid, useStyles2 } from '@grafana/ui';
import { PluginAngularBadge } from 'app/features/plugins/admin/components/Badges';
import { featureEnabled } from '@grafana/runtime';
import { Card, Grid, useStyles2, Stack, Badge } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import {
PluginAngularBadge,
PluginDeprecatedBadge,
PluginDisabledBadge,
PluginInstalledBadge,
PluginUpdateAvailableBadge,
} from 'app/features/plugins/admin/components/Badges';
import { getBadgeColor } from 'app/features/plugins/admin/components/Badges/sharedStyles';
import { isPluginUpdatable } from 'app/features/plugins/admin/helpers';
import { CatalogPlugin } from 'app/features/plugins/admin/types';
const getStyles = (theme: GrafanaTheme2) => ({
heading: css({
@@ -40,13 +51,28 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
});
export type CardGridItem = {
id: string;
name: string;
description: string;
url: string;
function PluginEnterpriseBadgeWithoutSignature() {
const customBadgeStyles = useStyles2(getBadgeColor);
if (featureEnabled('enterprise.plugins')) {
return <Badge text={t('get-enterprise.title', 'Enterprise')} color="blue" />;
}
return (
<Badge
icon="lock"
role="img"
aria-label={t('lock-icon', 'lock icon')}
text={t('get-enterprise.title', 'Enterprise')}
color="darkgrey"
className={customBadgeStyles}
title={t('get-enterprise.requires-license', 'Requires a Grafana Enterprise license')}
/>
);
}
export type CardGridItem = CatalogPlugin & {
logo?: string;
angularDetected?: boolean;
};
export interface CardGridProps {
@@ -75,12 +101,16 @@ export const CardGrid = ({ items, onClickItem }: CardGridProps) => {
<Card.Figure align="center" className={styles.figure}>
<img className={styles.logo} src={item.logo} alt="" />
</Card.Figure>
{item.angularDetected ? (
<Card.Meta className={styles.meta}>
<PluginAngularBadge />
</Card.Meta>
) : null}
<Card.Meta className={styles.meta}>
<Stack height="auto" wrap="wrap">
{item.isEnterprise && <PluginEnterpriseBadgeWithoutSignature />}
{item.isDeprecated && <PluginDeprecatedBadge />}
{item.isInstalled && <PluginInstalledBadge />}
{item.isDisabled && <PluginDisabledBadge error={item.error} />}
{isPluginUpdatable(item) && <PluginUpdateAvailableBadge plugin={item} />}
{item.angularDetected && <PluginAngularBadge />}
</Stack>
</Card.Meta>
</Card>
))}
</Grid>
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { TestProvider } from 'test/helpers/TestProvider';
import { PluginType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { getCatalogPluginMock, getPluginsStateMock } from 'app/features/plugins/admin/__mocks__';
import { CatalogPlugin } from 'app/features/plugins/admin/types';
@@ -17,7 +18,8 @@ jest.mock('@grafana/runtime', () => ({
useChromeHeaderHeight: jest.fn(),
}));
const renderPage = (plugins: CatalogPlugin[] = []): RenderResult => {
const renderPage = (plugins: CatalogPlugin[] = [], path = '/add-new-connection'): RenderResult => {
locationService.push(path);
return render(
<TestProvider storeState={{ plugins: getPluginsStateMock(plugins) }}>
<AddNewConnection />
@@ -31,7 +33,7 @@ const mockCatalogDataSourcePlugin = getCatalogPluginMock({
id: 'sample-data-source',
});
describe('Angular badge', () => {
describe('Badges', () => {
test('does not show angular badge for non-angular plugins', async () => {
renderPage([
getCatalogPluginMock({
@@ -61,6 +63,28 @@ describe('Angular badge', () => {
});
expect(screen.queryByText('Angular')).toBeInTheDocument();
});
test('shows enterprise and deprecated badges for plugins', async () => {
renderPage([
getCatalogPluginMock({
id: 'test-plugin',
name: 'test Plugin',
type: PluginType.datasource,
isEnterprise: true,
}),
getCatalogPluginMock({
id: 'test2-plugin',
name: 'test2 Plugin',
type: PluginType.datasource,
isDeprecated: true,
}),
]);
await waitFor(() => {
expect(screen.queryByText('test Plugin')).toBeInTheDocument();
});
expect(screen.queryByText('Enterprise')).toBeVisible();
expect(screen.queryByText('Deprecated')).toBeVisible();
});
});
describe('Add new connection', () => {
@@ -81,20 +105,57 @@ describe('Add new connection', () => {
expect(await screen.findByText('Sample data source')).toBeVisible();
});
test('should list plugins with update when filtering by update', async () => {
const { queryByText } = renderPage(
[
getCatalogPluginMock({
id: 'plugin-1',
name: 'Plugin 1',
isInstalled: true,
hasUpdate: true,
type: PluginType.datasource,
}),
getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: false }),
getCatalogPluginMock({
id: 'plugin-3',
name: 'Plugin 3',
isInstalled: true,
hasUpdate: true,
type: PluginType.datasource,
}),
getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: true, isCore: true }),
],
'/add-new-connection?filterBy=has-update'
);
await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
expect(queryByText('Plugin 3')).toBeInTheDocument();
expect(queryByText('Plugin 2')).not.toBeInTheDocument();
expect(queryByText('Plugin 4')).not.toBeInTheDocument();
});
test('renders card if search term matches', async () => {
renderPage([getCatalogPluginMock(), mockCatalogDataSourcePlugin]);
const searchField = await screen.findByRole('textbox');
renderPage(
[
getCatalogPluginMock({ type: PluginType.datasource, id: 'test1', name: 'test33' }),
getCatalogPluginMock({ id: 'test2', type: PluginType.datasource, name: 'querymatches' }),
],
'/add-new-connection?filterBy=all&sortBy=nameAsc&search=querymatches'
);
expect(await screen.findByText('querymatches')).toBeVisible();
});
await userEvent.type(searchField, 'ampl');
expect(await screen.findByText('Sample data source')).toBeVisible();
test('renders no results if search term does not match', async () => {
renderPage(
[
getCatalogPluginMock({ type: PluginType.datasource, id: 'test1', name: 'test33' }),
getCatalogPluginMock({ id: 'test2', type: PluginType.datasource, name: 'querymatches' }),
],
'/add-new-connection?filterBy=all&sortBy=nameAsc&search=dfvdfv'
);
await userEvent.clear(searchField);
await userEvent.type(searchField, 'cramp');
expect(screen.queryByText('No results matching your query were found')).toBeInTheDocument();
await userEvent.clear(searchField);
expect(await screen.findByText('Sample data source')).toBeVisible();
expect(await screen.findByText('No results matching your query were found')).toBeVisible();
});
test('shows a "No access" modal if the user does not have permissions to create datasources', async () => {
@@ -1,14 +1,19 @@
import { css } from '@emotion/css';
import { useMemo, useState, FormEvent, MouseEvent } from 'react';
import { useMemo, useState, MouseEvent } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { GrafanaTheme2, PluginType } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2, LoadingPlaceholder, EmptyState } from '@grafana/ui';
import { PluginType, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { locationSearchToObject, reportInteraction } from '@grafana/runtime';
import { LoadingPlaceholder, EmptyState, Field, RadioButtonGroup, Tooltip, Combobox, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { t } from 'app/core/internationalization';
import { t, Trans } from 'app/core/internationalization';
import { HorizontalGroup } from 'app/features/plugins/admin/components/HorizontalGroup';
import { RoadmapLinks } from 'app/features/plugins/admin/components/RoadmapLinks';
import { useGetAll } from 'app/features/plugins/admin/state/hooks';
import { SearchField } from 'app/features/plugins/admin/components/SearchField';
import { Sorters } from 'app/features/plugins/admin/helpers';
import { useHistory } from 'app/features/plugins/admin/hooks/useHistory';
import { useGetAll, useIsRemotePluginsAvailable } from 'app/features/plugins/admin/state/hooks';
import { AccessControlAction } from 'app/types';
import { ROUTES } from '../../constants';
@@ -16,7 +21,6 @@ import { ROUTES } from '../../constants';
import { CardGrid, type CardGridItem } from './CardGrid';
import { CategoryHeader } from './CategoryHeader';
import { NoAccessModal } from './NoAccessModal';
import { Search } from './Search';
const getStyles = (theme: GrafanaTheme2) => ({
spacer: css({
@@ -28,6 +32,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
modalContent: css({
overflow: 'visible',
}),
actionBar: css({
[theme.breakpoints.up('xl')]: {
marginLeft: 'auto',
},
}),
});
export function AddNewConnection() {
@@ -35,32 +44,34 @@ export function AddNewConnection() {
const searchTerm = queryParams.search ? String(queryParams.search) : '';
const [isNoAccessModalOpen, setIsNoAccessModalOpen] = useState(false);
const [focusedItem, setFocusedItem] = useState<CardGridItem | null>(null);
const styles = useStyles2(getStyles);
const location = useLocation();
const history = useHistory();
const locationSearch = locationSearchToObject(location.search);
const sortBy = (locationSearch.sortBy as Sorters) || Sorters.nameAsc;
const filterBy = locationSearch.filterBy?.toString() || 'all';
const canCreateDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
const handleSearchChange = (e: FormEvent<HTMLInputElement>) => {
const styles = useStyles2(getStyles);
const handleSearchChange = (val: string) => {
setQueryParams({
search: e.currentTarget.value.toLowerCase(),
search: val,
});
};
const remotePluginsAvailable = useIsRemotePluginsAvailable();
const { error, plugins, isLoading } = useGetAll({
keyword: searchTerm,
type: PluginType.datasource,
});
const cardGridItems = useMemo(
() =>
plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
logo: plugin.info.logos.small,
url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
angularDetected: plugin.angularDetected,
})),
[plugins]
const { error, plugins, isLoading } = useGetAll(
{
keyword: searchTerm,
type: PluginType.datasource,
isInstalled: filterBy === 'installed' ? true : undefined,
hasUpdate: filterBy === 'has-update' ? true : undefined,
},
sortBy
);
const filterByOptions = [
{ value: 'all', label: 'All' },
{ value: 'installed', label: 'Installed' },
{ value: 'has-update', label: 'New Updates' },
];
const onClickCardGridItem = (e: MouseEvent<HTMLElement>, item: CardGridItem) => {
if (!canCreateDataSources) {
@@ -85,20 +96,86 @@ export function AddNewConnection() {
setFocusedItem(null);
};
const cardGridItems = useMemo(
() =>
plugins.map((plugin) => ({
...plugin,
logo: plugin.info.logos.small,
url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
})),
[plugins]
);
const onSortByChange = (value: SelectableValue<string>) => {
history.push({ query: { sortBy: value.value } });
};
const onFilterByChange = (value: string) => {
history.push({ query: { filterBy: value } });
};
const showNoResults = useMemo(() => !isLoading && !error && plugins.length < 1, [isLoading, error, plugins]);
const categoryHeaderLabel = t('connections.connect-data.category-header-label', 'Data sources');
return (
<>
{focusedItem && <NoAccessModal item={focusedItem} isOpen={isNoAccessModalOpen} onDismiss={closeModal} />}
<Search onChange={handleSearchChange} value={searchTerm} />
{/* We need this extra spacing when there are no filters */}
<div className={styles.spacer} />
<HorizontalGroup wrap>
<Field label={t('common.search', 'Search')}>
<SearchField value={searchTerm} onSearch={handleSearchChange} />
</Field>
<HorizontalGroup className={styles.actionBar}>
{/* Filter by installed / all */}
{remotePluginsAvailable ? (
<Field label={t('plugins.filter.state', 'State')}>
<RadioButtonGroup value={filterBy} onChange={onFilterByChange} options={filterByOptions} />
</Field>
) : (
<Tooltip
content={t(
'plugins.filter.disabled',
'This filter has been disabled because the Grafana server cannot access grafana.com'
)}
placement="top"
>
<div>
<Field label={t('plugins.filter.state', 'State')}>
<RadioButtonGroup
disabled={true}
value={filterBy}
onChange={onFilterByChange}
options={filterByOptions}
/>
</Field>
</div>
</Tooltip>
)}
{/* Sorting */}
<Field label={t('plugins.filter.sort', 'Sort')}>
<Combobox
aria-label={t('plugins.filter.sort-list', 'Sort Plugins List')}
width={24}
value={sortBy?.toString()}
onChange={onSortByChange}
options={[
{ value: 'nameAsc', label: 'By name (A-Z)' },
{ value: 'nameDesc', label: 'By name (Z-A)' },
{ value: 'updated', label: 'By updated date' },
{ value: 'published', label: 'By published date' },
{ value: 'downloads', label: 'By downloads' },
]}
/>
</Field>
</HorizontalGroup>
</HorizontalGroup>
<CategoryHeader iconName="database" label={categoryHeaderLabel} />
{isLoading ? (
<LoadingPlaceholder text="Loading..." />
<LoadingPlaceholder text={t('common.loading', 'Loading...')} />
) : !!error ? (
<p>Error: {error.message}</p>
<Trans i18nKey="alerting.policies.update-errors.error-code" values={{ error: error.message }}>
Error message: "{{ error: error.message }}"
</Trans>
) : (
<CardGrid items={cardGridItems} onClickItem={onClickCardGridItem} />
)}
@@ -0,0 +1,29 @@
import { render, screen, fireEvent } from '@testing-library/react';
import UpdateAllButton from './UpdateAllButton';
describe('UpdateAllButton', () => {
const onUpdateAllMock = jest.fn();
beforeEach(() => {
onUpdateAllMock.mockClear();
});
it('should display "No updates available" when disabled', () => {
render(<UpdateAllButton disabled={true} onUpdateAll={onUpdateAllMock} updatablePluginsLength={0} />);
expect(screen.getByText('No updates available')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeDisabled();
});
it('should display update count and be clickable when enabled', () => {
render(<UpdateAllButton disabled={false} onUpdateAll={onUpdateAllMock} updatablePluginsLength={3} />);
const button = screen.getByRole('button');
expect(button).toHaveTextContent('Update all (3)');
expect(button).toBeEnabled();
fireEvent.click(button);
expect(onUpdateAllMock).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,24 @@
import { Button } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
interface UpdateAllButtonProps {
disabled: boolean;
onUpdateAll: () => void;
updatablePluginsLength: number;
}
const UpdateAllButton = ({ disabled, onUpdateAll, updatablePluginsLength }: UpdateAllButtonProps) => {
return (
<Button disabled={disabled} onClick={onUpdateAll}>
{disabled ? (
<Trans i18nKey="plugins.catalog.no-updates-available">No updates available</Trans>
) : (
<Trans i18nKey="plugins.catalog.update-all.button" values={{ length: updatablePluginsLength }}>
Update all ({{ length }})
</Trans>
)}
</Button>
);
};
export default UpdateAllButton;
@@ -4,9 +4,8 @@ import { useLocation } from 'react-router-dom-v5-compat';
import { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data';
import { locationSearchToObject } from '@grafana/runtime';
import { Select, RadioButtonGroup, useStyles2, Tooltip, Field, Button } from '@grafana/ui';
import { Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { Trans } from 'app/core/internationalization';
import { getNavModel } from 'app/core/selectors/navModel';
import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants';
import { useSelector } from 'app/types';
@@ -15,6 +14,7 @@ import { HorizontalGroup } from '../components/HorizontalGroup';
import { PluginList } from '../components/PluginList';
import { RoadmapLinks } from '../components/RoadmapLinks';
import { SearchField } from '../components/SearchField';
import UpdateAllButton from '../components/UpdateAllButton';
import { UpdateAllModal } from '../components/UpdateAllModal';
import { Sorters } from '../helpers';
import { useHistory } from '../hooks/useHistory';
@@ -86,15 +86,17 @@ export default function Browse() {
.
</div>
);
const updateAll = (
<Button disabled={disableUpdateAllButton} onClick={onUpdateAll}>
<Trans i18nKey="plugins.catalog.update-all.button">Update all</Trans>
{disableUpdateAllButton ? '' : ` (${updatablePlugins.length})`}
</Button>
const updateAllButton = (
<UpdateAllButton
disabled={disableUpdateAllButton}
onUpdateAll={onUpdateAll}
updatablePluginsLength={updatablePlugins.length}
/>
);
return (
<Page navModel={navModel} actions={updateAll} subTitle={subTitle}>
<Page navModel={navModel} actions={updateAllButton} subTitle={subTitle}>
<Page.Contents>
<HorizontalGroup wrap>
<Field label="Search">
+14 -1
View File
@@ -810,6 +810,7 @@
"collapse": "Collapse",
"edit": "Edit",
"help": "Help",
"loading": "Loading...",
"locale": {
"default": "Default"
},
@@ -1642,6 +1643,10 @@
"incomplete-request-error": "Sorry, I was unable to complete your request. Please try again.",
"send-custom-feedback": "Send"
},
"get-enterprise": {
"requires-license": "Requires a Grafana Enterprise license",
"title": "Enterprise"
},
"grafana-ui": {
"action-editor": {
"button": {
@@ -2045,6 +2050,7 @@
"render-image-error-description": "An error occurred when generating the image"
}
},
"lock-icon": "lock icon",
"login": {
"divider": {
"connecting-text": "or"
@@ -2839,10 +2845,11 @@
},
"plugins": {
"catalog": {
"no-updates-available": "No updates available",
"update-all": {
"all-plugins-updated": "All plugins updated!",
"available-header": "Available",
"button": "Update all",
"button": "Update all ({{length}})",
"cloud-update-message": "* It may take a few minutes for the plugins to be available for usage.",
"error": "Error updating plugin:",
"error-status-text": "failed - see error messages",
@@ -2898,6 +2905,12 @@
"empty-state": {
"message": "No plugins found"
},
"filter": {
"disabled": "This filter has been disabled because the Grafana server cannot access grafana.com",
"sort": "Sort",
"sort-list": "Sort Plugins List",
"state": "State"
},
"plugin-help": {
"error": "An error occurred when loading help.",
"not-found": "No query help could be found."
+14 -1
View File
@@ -810,6 +810,7 @@
"collapse": "Cőľľäpşę",
"edit": "Ēđįŧ",
"help": "Ħęľp",
"loading": "Ŀőäđįʼnģ...",
"locale": {
"default": "Đęƒäūľŧ"
},
@@ -1642,6 +1643,10 @@
"incomplete-request-error": "Ŝőřřy, Ĩ ŵäş ūʼnäþľę ŧő čőmpľęŧę yőūř řęqūęşŧ. Pľęäşę ŧřy äģäįʼn.",
"send-custom-feedback": "Ŝęʼnđ"
},
"get-enterprise": {
"requires-license": "Ŗęqūįřęş ä Ğřäƒäʼnä Ēʼnŧęřpřįşę ľįčęʼnşę",
"title": "Ēʼnŧęřpřįşę"
},
"grafana-ui": {
"action-editor": {
"button": {
@@ -2045,6 +2050,7 @@
"render-image-error-description": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ģęʼnęřäŧįʼnģ ŧĥę įmäģę"
}
},
"lock-icon": "ľőčĸ įčőʼn",
"login": {
"divider": {
"connecting-text": "őř"
@@ -2839,10 +2845,11 @@
},
"plugins": {
"catalog": {
"no-updates-available": "Ńő ūpđäŧęş äväįľäþľę",
"update-all": {
"all-plugins-updated": "Åľľ pľūģįʼnş ūpđäŧęđ!",
"available-header": "Åväįľäþľę",
"button": "Ůpđäŧę äľľ",
"button": "Ůpđäŧę äľľ ({{length}})",
"cloud-update-message": "* Ĩŧ mäy ŧäĸę ä ƒęŵ mįʼnūŧęş ƒőř ŧĥę pľūģįʼnş ŧő þę äväįľäþľę ƒőř ūşäģę.",
"error": "Ēřřőř ūpđäŧįʼnģ pľūģįʼn:",
"error-status-text": "ƒäįľęđ - şęę ęřřőř męşşäģęş",
@@ -2898,6 +2905,12 @@
"empty-state": {
"message": "Ńő pľūģįʼnş ƒőūʼnđ"
},
"filter": {
"disabled": "Ŧĥįş ƒįľŧęř ĥäş þęęʼn đįşäþľęđ þęčäūşę ŧĥę Ğřäƒäʼnä şęřvęř čäʼnʼnőŧ äččęşş ģřäƒäʼnä.čőm",
"sort": "Ŝőřŧ",
"sort-list": "Ŝőřŧ Pľūģįʼnş Ŀįşŧ",
"state": "Ŝŧäŧę"
},
"plugin-help": {
"error": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ľőäđįʼnģ ĥęľp.",
"not-found": "Ńő qūęřy ĥęľp čőūľđ þę ƒőūʼnđ."