Connections: New "Connect data" page with data source catalog (#56772)

* remove Plugins and CloudIntegrations tab and add ConnectData tab

* ConnectData: add Search component and use it

* ConnectData: add DataSourcePluginList component

* add CardGrid component

* add CategoryHeader component

* ConnectData: restructure content

DataSourcePluginList is removed, because its responsibilities are
actually the same as ConnectData's responsibilities.

NoResults was added as a reusable component, and was moved out of
CardGrid, since there could be more CardGrid on one page, but only one
NoResults.

* fix spacer

* use LoadingPlaceholder

* CardGrid: add margin

* generalize CardGridProps

* move isLoading and error into CardGrid

We'd like CardGrid to be reusable, even multiple times within a page.
In this case, it's better UX if we show the loading or error states per
card grid, not for the whole page.

* ConnectData: fix NoResults condition

* fix and add meaningful tests

* fix indentation

* move isLoading and error back to ConnectData

* make `url` required for CardGrid items
This commit is contained in:
mikkancso 2022-10-20 15:53:10 +02:00 committed by GitHub
parent 8b2d35bdb9
commit a4e66a2a01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 261 additions and 79 deletions

View File

@ -546,19 +546,11 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
})
children = append(children, &navtree.NavLink{
Id: baseId + "-plugins",
Text: "Plugins",
Id: baseId + "-connect-data",
Text: "Connect Data",
Icon: "plug",
SubTitle: "Manage plugins",
Url: baseUrl + "/plugins",
})
children = append(children, &navtree.NavLink{
Id: baseId + "-cloud-integrations",
Text: "Cloud integrations",
Icon: "bolt",
SubTitle: "Manage your cloud integrations",
Url: baseUrl + "/cloud-integrations",
SubTitle: "Manage data sources",
Url: baseUrl + "/connect-data",
})
navLink = &navtree.NavLink{

View File

@ -8,6 +8,8 @@ import { getMockDataSources } from 'app/features/datasources/__mocks__';
import * as api from 'app/features/datasources/api';
import { configureStore } from 'app/store/configureStore';
import { getPluginsStateMock } from '../plugins/admin/__mocks__';
import ConnectionsPage from './ConnectionsPage';
import { navIndex } from './__mocks__/store.navIndex.mock';
import { ROUTE_BASE_ID, ROUTES } from './constants';
@ -16,7 +18,7 @@ jest.mock('app/features/datasources/api');
const renderPage = (path = `/${ROUTE_BASE_ID}`): RenderResult => {
// @ts-ignore
const store = configureStore({ navIndex });
const store = configureStore({ navIndex, plugins: getPluginsStateMock([]) });
locationService.push(path);
return render(
@ -35,13 +37,11 @@ describe('Connections Page', () => {
(api.getDataSources as jest.Mock) = jest.fn().mockResolvedValue(mockDatasources);
});
test('shows all the four tabs', async () => {
test('shows all tabs', async () => {
renderPage();
expect(await screen.findByLabelText('Tab Data sources')).toBeVisible();
expect(await screen.findByLabelText('Tab Plugins')).toBeVisible();
expect(await screen.findByLabelText('Tab Cloud integrations')).toBeVisible();
expect(await screen.findByLabelText('Tab Recorded queries')).toBeVisible();
expect(await screen.findByLabelText('Tab Connect Data')).toBeVisible();
});
test('shows the "Data sources" tab by default', async () => {
@ -52,10 +52,8 @@ describe('Connections Page', () => {
});
test('renders the correct tab even if accessing it with a "sub-url"', async () => {
renderPage(`${ROUTES.Plugins}/foo`);
renderPage(`${ROUTES.ConnectData}`);
// Check if it still renders the plugins tab
expect(await screen.findByText('The list of plugins is under development')).toBeVisible();
expect(screen.queryByText('The list of data sources is under development.')).not.toBeInTheDocument();
expect(screen.queryByText('No results matching your query were found.')).toBeInTheDocument();
});
});

View File

@ -8,9 +8,8 @@ import { DataSourcesRoutesContext } from 'app/features/datasources/state';
import { ROUTES } from './constants';
import { useNavModel } from './hooks/useNavModel';
import { CloudIntegrations } from './tabs/CloudIntegrations';
import { ConnectData } from './tabs/ConnectData';
import { DataSourcesEdit } from './tabs/DataSourcesEdit';
import { Plugins } from './tabs/Plugins';
export default function ConnectionsPage() {
const navModel = useNavModel();
@ -30,8 +29,7 @@ export default function ConnectionsPage() {
<Route path={ROUTES.DataSourcesNew} component={NewDataSource} />
<Route path={ROUTES.DataSourcesEdit} component={DataSourcesEdit} />
<Route path={ROUTES.DataSources} component={DataSourcesList} />
<Route path={ROUTES.Plugins} component={Plugins} />
<Route path={ROUTES.CloudIntegrations} component={CloudIntegrations} />
<Route path={ROUTES.ConnectData} component={ConnectData} />
{/* Default page */}
<Route component={DataSourcesList} />

View File

@ -1227,27 +1227,11 @@ export const navIndex: NavIndex = {
active: false,
},
{
id: 'connections-plugins',
text: 'Plugins',
description: 'Manage plugins',
id: 'connections-connect-data',
text: 'Connect Data',
description: 'Manage data sources',
icon: 'plug',
url: '/connections/plugins',
active: false,
},
{
id: 'connections-cloud-integrations',
text: 'Cloud integrations',
description: 'Manage your cloud integrations',
icon: 'bolt',
url: '/connections/cloud-integrations',
active: true,
},
{
id: 'connections-recorded-queries',
text: 'Recorded queries',
description: 'Manage your recorded queries',
icon: 'record-audio',
url: '/connections/recorded-queries',
url: '/connections/connect-data',
active: false,
},
],

View File

@ -9,6 +9,5 @@ export const ROUTES = {
DataSourcesNew: `/${ROUTE_BASE_ID}/datasources/new`,
DataSourcesEdit: `/${ROUTE_BASE_ID}/datasources/edit/:uid`,
DataSourcesDashboards: `/${ROUTE_BASE_ID}/datasources/edit/:uid/dashboards`,
Plugins: `/${ROUTE_BASE_ID}/plugins`,
CloudIntegrations: `/${ROUTE_BASE_ID}/cloud-integrations`,
ConnectData: `/${ROUTE_BASE_ID}/connect-data`,
} as const;

View File

@ -1,25 +0,0 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { AppPluginLoader } from 'app/features/plugins/components/AppPluginLoader';
import { CLOUD_ONBOARDING_APP_ID, ROUTES } from '../../constants';
export function CloudIntegrations() {
const s = useStyles2(getStyles);
return (
<div className={s.container}>
<AppPluginLoader id={CLOUD_ONBOARDING_APP_ID} basePath={ROUTES.CloudIntegrations} />
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
// We would like to force the app to stay inside the provided tab
container: css`
position: relative;
`,
});

View File

@ -1 +0,0 @@
export * from './CloudIntegrations';

View File

@ -0,0 +1,67 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, useStyles2 } from '@grafana/ui';
const getStyles = (theme: GrafanaTheme2) => ({
sourcesList: css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
list-style: none;
margin-bottom: 80px;
`,
card: css`
height: 90px;
padding: 0px 24px;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.6);
`,
cardContent: css`
display: flex;
align-items: center;
`,
logoWrapper: css`
display: flex;
justify-content: center;
margin-right: 8px;
width: 32px;
height: 32px;
img {
max-width: 100%;
max-height: 100%;
align-self: center;
}
`,
label: css`
color: ${theme.colors.text.primary};
margin-bottom: 0;
`,
});
export interface CardGridProps {
items: Array<{ id: string; name: string; url: string; logo?: string }>;
}
export const CardGrid: FC<CardGridProps> = ({ items }) => {
const styles = useStyles2(getStyles);
return (
<ul className={styles.sourcesList}>
{items.map((item) => (
<Card key={item.id} className={styles.card} href={item.url}>
<Card.Heading>
<div className={styles.cardContent}>
{item.logo && (
<div className={styles.logoWrapper}>
<img src={item.logo} alt={`logo of ${item.name}`} />
</div>
)}
<h4 className={styles.label}>{item.name}</h4>
</div>
</Card.Heading>
</Card>
))}
</ul>
);
};

View File

@ -0,0 +1 @@
export * from './CardGrid';

View File

@ -0,0 +1,27 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
const getStyles = (theme: GrafanaTheme2) => ({
categoryHeader: css`
align-items: center;
display: flex;
margin-bottom: 24px;
`,
categoryLabel: css`
margin-bottom: 0px;
margin-left: 8px;
`,
});
export const CategoryHeader: React.FC<{ iconName: IconName; label: string }> = ({ iconName, label }) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.categoryHeader}>
<Icon name={iconName} size="xl" />
<h3 className={styles.categoryLabel}>{label}</h3>
</div>
);
};

View File

@ -0,0 +1 @@
export * from './CategoryHeader';

View File

@ -0,0 +1,48 @@
import { fireEvent, render, RenderResult, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { CatalogPlugin } from 'app/features/plugins/admin/types';
import { configureStore } from 'app/store/configureStore';
import { getCatalogPluginMock, getPluginsStateMock } from '../../../plugins/admin/__mocks__';
import { ConnectData } from './ConnectData';
jest.mock('app/features/datasources/api');
const renderPage = (plugins: CatalogPlugin[] = []): RenderResult => {
// @ts-ignore
const store = configureStore({ plugins: getPluginsStateMock(plugins) });
return render(
<Provider store={store}>
<ConnectData />
</Provider>
);
};
describe('Connect Data', () => {
test('renders no results if the plugins list is empty', async () => {
renderPage();
expect(screen.queryByText('No results matching your query were found.')).toBeInTheDocument();
});
test('renders card if plugins list is populated', async () => {
renderPage([getCatalogPluginMock()]);
expect(await screen.findByText('Zabbix')).toBeVisible();
});
test('renders card if search term matches', async () => {
renderPage([getCatalogPluginMock()]);
const searchField = await screen.findByRole('textbox');
fireEvent.change(searchField, { target: { value: 'abbi' } });
expect(await screen.findByText('Zabbix')).toBeVisible();
fireEvent.change(searchField, { target: { value: 'rabbit' } });
expect(screen.queryByText('No results matching your query were found.')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,56 @@
import { css } from '@emotion/css';
import React, { useMemo, useState } from 'react';
import { useStyles2, LoadingPlaceholder } from '@grafana/ui';
import { useGetAllWithFilters } from 'app/features/plugins/admin/state/hooks';
import { CardGrid } from './CardGrid';
import { CategoryHeader } from './CategoryHeader';
import { NoResults } from './NoResults';
import { Search } from './Search';
const getStyles = () => ({
spacer: css`
height: 16px;
`,
});
export function ConnectData() {
const [searchTerm, setSearchTerm] = useState('');
const styles = useStyles2(getStyles);
const handleSearchChange = (e: React.FormEvent<HTMLInputElement>) => {
setSearchTerm(e.currentTarget.value.toLowerCase());
};
const { isLoading, error, plugins } = useGetAllWithFilters({ query: searchTerm, filterBy: '' });
const cardGridItems = useMemo(
() =>
plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
logo: plugin.info.logos.small,
url: `plugins/${plugin.id}`,
})),
[plugins]
);
const showNoResults = useMemo(() => !isLoading && !error && plugins.length < 1, [isLoading, error, plugins]);
return (
<>
<Search onChange={handleSearchChange} />
{/* We need this extra spacing when there are no filters */}
<div className={styles.spacer} />
<CategoryHeader iconName="database" label="Data sources" />
{isLoading ? (
<LoadingPlaceholder text="Loading..." />
) : !!error ? (
<p>Error: {error.message}</p>
) : (
<CardGrid items={cardGridItems} />
)}
{showNoResults && <NoResults />}
</>
);
}

View File

@ -0,0 +1,18 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { useStyles2 } from '@grafana/ui';
const getStyles = () => ({
noResults: css`
text-align: center;
padding: 50px 0;
font-style: italic;
`,
});
export const NoResults: FC = () => {
const styles = useStyles2(getStyles);
return <p className={styles.noResults}>No results matching your query were found.</p>;
};

View File

@ -0,0 +1 @@
export * from './NoResults';

View File

@ -0,0 +1,22 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { Icon, Input, useStyles } from '@grafana/ui';
const getStyles = () => ({
searchContainer: css`
display: flex;
margin: 16px 0;
justify-content: space-between;
`,
});
export const Search: FC<{ onChange: (e: React.FormEvent<HTMLInputElement>) => void }> = ({ onChange }) => {
const styles = useStyles(getStyles);
return (
<div className={styles.searchContainer}>
<Input onChange={onChange} prefix={<Icon name="search" />} placeholder="Search all" aria-label="Search all" />
</div>
);
};

View File

@ -0,0 +1 @@
export * from './Search';

View File

@ -0,0 +1 @@
export * from './ConnectData';

View File

@ -1,5 +0,0 @@
import React from 'react';
export function Plugins() {
return <div>The list of plugins is under development</div>;
}

View File

@ -1 +0,0 @@
export * from './Plugins';