mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
8b2d35bdb9
commit
a4e66a2a01
@ -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{
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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} />
|
||||
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
`,
|
||||
});
|
@ -1 +0,0 @@
|
||||
export * from './CloudIntegrations';
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './CardGrid';
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './CategoryHeader';
|
@ -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();
|
||||
});
|
||||
});
|
@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>;
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './NoResults';
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './Search';
|
@ -0,0 +1 @@
|
||||
export * from './ConnectData';
|
@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export function Plugins() {
|
||||
return <div>The list of plugins is under development</div>;
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './Plugins';
|
Loading…
Reference in New Issue
Block a user