mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
NestedFolders: New Browse Dashboards views (#66003)
* scaffold new browse routes * a part of rtk query * load nested data * . * link nested dashboards items * add comment about bad code, update codeowners * tidies
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -377,6 +377,7 @@ lerna.json @grafana/frontend-ops
|
|||||||
/public/app/features/query/ @grafana/dashboards-squad
|
/public/app/features/query/ @grafana/dashboards-squad
|
||||||
/public/app/features/sandbox/ @grafana/grafana-frontend-platform
|
/public/app/features/sandbox/ @grafana/grafana-frontend-platform
|
||||||
/public/app/features/scenes/ @grafana/dashboards-squad
|
/public/app/features/scenes/ @grafana/dashboards-squad
|
||||||
|
/public/app/features/browse-dashboards/ @grafana/grafana-frontend-platform
|
||||||
/public/app/features/search/ @grafana/grafana-frontend-platform
|
/public/app/features/search/ @grafana/grafana-frontend-platform
|
||||||
/public/app/features/serviceaccounts/ @grafana/grafana-authnz-team
|
/public/app/features/serviceaccounts/ @grafana/grafana-authnz-team
|
||||||
/public/app/features/storage/ @grafana/grafana-app-platform-squad
|
/public/app/features/storage/ @grafana/grafana-app-platform-squad
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import sharedReducers from 'app/core/reducers';
|
|||||||
import ldapReducers from 'app/features/admin/state/reducers';
|
import ldapReducers from 'app/features/admin/state/reducers';
|
||||||
import alertingReducers from 'app/features/alerting/state/reducers';
|
import alertingReducers from 'app/features/alerting/state/reducers';
|
||||||
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
||||||
|
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||||
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
||||||
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
||||||
@@ -48,6 +49,7 @@ const rootReducers = {
|
|||||||
plugins: pluginsReducer,
|
plugins: pluginsReducer,
|
||||||
[alertingApi.reducerPath]: alertingApi.reducer,
|
[alertingApi.reducerPath]: alertingApi.reducer,
|
||||||
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
|
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
|
||||||
|
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
|
||||||
};
|
};
|
||||||
|
|
||||||
const addedReducers = {};
|
const addedReducers = {};
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { memo, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { locationSearchToObject } from '@grafana/runtime';
|
||||||
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
|
||||||
|
import { buildNavModel } from '../folders/state/navModel';
|
||||||
|
import { parseRouteParams } from '../search/utils';
|
||||||
|
|
||||||
|
import { skipToken, useGetFolderQuery } from './api/browseDashboardsAPI';
|
||||||
|
import { BrowseActions } from './components/BrowseActions';
|
||||||
|
import { BrowseView } from './components/BrowseView';
|
||||||
|
import { SearchView } from './components/SearchView';
|
||||||
|
|
||||||
|
export interface BrowseDashboardsPageRouteParams {
|
||||||
|
uid?: string;
|
||||||
|
slug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props extends GrafanaRouteComponentProps<BrowseDashboardsPageRouteParams> {}
|
||||||
|
|
||||||
|
// New Browse/Manage/Search Dashboards views for nested folders
|
||||||
|
|
||||||
|
export const BrowseDashboardsPage = memo(({ match, location }: Props) => {
|
||||||
|
const { uid: folderUID } = match.params;
|
||||||
|
|
||||||
|
const searchState = useMemo(() => {
|
||||||
|
return parseRouteParams(locationSearchToObject(location.search));
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
const { data: folderDTO } = useGetFolderQuery(folderUID ?? skipToken);
|
||||||
|
const navModel = useMemo(() => (folderDTO ? buildNavModel(folderDTO) : undefined), [folderDTO]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page navId="dashboards/browse" pageNav={navModel}>
|
||||||
|
<Page.Contents>
|
||||||
|
<BrowseActions />
|
||||||
|
|
||||||
|
{folderDTO && <pre>{JSON.stringify(folderDTO, null, 2)}</pre>}
|
||||||
|
|
||||||
|
{searchState.query ? <SearchView searchState={searchState} /> : <BrowseView folderUID={folderUID} />}
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BrowseDashboardsPage.displayName = 'BrowseDashboardsPage';
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||||
|
|
||||||
|
import { FolderDTO } from 'app/types';
|
||||||
|
|
||||||
|
// interface RequestOptions extends BackendSrvRequest {
|
||||||
|
// manageError?: (err: unknown) => { error: unknown };
|
||||||
|
// showErrorAlert?: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// function createBackendSrvBaseQuery({ baseURL }: { baseURL: string }): BaseQueryFn<RequestOptions> {
|
||||||
|
// async function backendSrvBaseQuery(requestOptions: RequestOptions) {
|
||||||
|
// try {
|
||||||
|
// const { data: responseData, ...meta } = await lastValueFrom(
|
||||||
|
// getBackendSrv().fetch({
|
||||||
|
// ...requestOptions,
|
||||||
|
// url: baseURL + requestOptions.url,
|
||||||
|
// showErrorAlert: requestOptions.showErrorAlert,
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
// return { data: responseData, meta };
|
||||||
|
// } catch (error) {
|
||||||
|
// return requestOptions.manageError ? requestOptions.manageError(error) : { error };
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return backendSrvBaseQuery;
|
||||||
|
// }
|
||||||
|
|
||||||
|
export const browseDashboardsAPI = createApi({
|
||||||
|
reducerPath: 'browse-dashboards',
|
||||||
|
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
|
||||||
|
endpoints: (builder) => ({
|
||||||
|
getFolder: builder.query<FolderDTO, string>({
|
||||||
|
query: (folderUID) => `/folders/${folderUID}`,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { useGetFolderQuery } = browseDashboardsAPI;
|
||||||
|
export { skipToken } from '@reduxjs/toolkit/query/react';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Input } from '@grafana/ui';
|
||||||
|
import { ActionRow } from 'app/features/search/page/components/ActionRow';
|
||||||
|
import { SearchLayout } from 'app/features/search/types';
|
||||||
|
|
||||||
|
export function BrowseActions() {
|
||||||
|
const fakeState = useMemo(() => {
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
tag: [],
|
||||||
|
starred: false,
|
||||||
|
layout: SearchLayout.Folders,
|
||||||
|
eventTrackingNamespace: 'manage_dashboards' as const,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Input placeholder="Search box" />
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<ActionRow
|
||||||
|
includePanels={false}
|
||||||
|
state={fakeState}
|
||||||
|
getTagOptions={() => Promise.resolve([])}
|
||||||
|
getSortOptions={() => Promise.resolve([])}
|
||||||
|
onLayoutChange={() => {}}
|
||||||
|
onSortChange={() => {}}
|
||||||
|
onStarredFilterChange={() => {}}
|
||||||
|
onTagFilterChange={() => {}}
|
||||||
|
onDatasourceChange={() => {}}
|
||||||
|
onPanelTypeChange={() => {}}
|
||||||
|
onSetIncludePanels={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
public/app/features/browse-dashboards/components/BrowseView.tsx
Normal file
101
public/app/features/browse-dashboards/components/BrowseView.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Icon, IconButton, Link } from '@grafana/ui';
|
||||||
|
import { getFolderChildren } from 'app/features/search/service/folders';
|
||||||
|
import { DashboardViewItem } from 'app/features/search/types';
|
||||||
|
|
||||||
|
type NestedData = Record<string, DashboardViewItem[] | undefined>;
|
||||||
|
|
||||||
|
interface BrowseViewProps {
|
||||||
|
folderUID: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowseView({ folderUID }: BrowseViewProps) {
|
||||||
|
const [nestedData, setNestedData] = useState<NestedData>({});
|
||||||
|
|
||||||
|
// Note: entire implementation of this component must be replaced.
|
||||||
|
// This is just to show proof of concept for fetching and showing the data
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const folderKey = folderUID ?? '$$root';
|
||||||
|
|
||||||
|
getFolderChildren(folderUID, undefined, true).then((children) => {
|
||||||
|
setNestedData((v) => ({ ...v, [folderKey]: children }));
|
||||||
|
});
|
||||||
|
}, [folderUID]);
|
||||||
|
|
||||||
|
const items = nestedData[folderUID ?? '$$root'] ?? [];
|
||||||
|
|
||||||
|
const handleNodeClick = useCallback(
|
||||||
|
(uid: string) => {
|
||||||
|
if (nestedData[uid]) {
|
||||||
|
setNestedData((v) => ({ ...v, [uid]: undefined }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFolderChildren(uid).then((children) => {
|
||||||
|
setNestedData((v) => ({ ...v, [uid]: children }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[nestedData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>Browse view</p>
|
||||||
|
|
||||||
|
<ul style={{ marginLeft: 16 }}>
|
||||||
|
{items.map((item) => {
|
||||||
|
return (
|
||||||
|
<li key={item.uid}>
|
||||||
|
<BrowseItem item={item} nestedData={nestedData} onFolderClick={handleNodeClick} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowseItem({
|
||||||
|
item,
|
||||||
|
nestedData,
|
||||||
|
onFolderClick,
|
||||||
|
}: {
|
||||||
|
item: DashboardViewItem;
|
||||||
|
nestedData: NestedData;
|
||||||
|
onFolderClick: (uid: string) => void;
|
||||||
|
}) {
|
||||||
|
const childItems = nestedData[item.uid];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{item.kind === 'folder' ? (
|
||||||
|
<IconButton onClick={() => onFolderClick(item.uid)} name={childItems ? 'angle-down' : 'angle-right'} />
|
||||||
|
) : (
|
||||||
|
<span style={{ paddingRight: 20 }} />
|
||||||
|
)}
|
||||||
|
<Icon name={item.kind === 'folder' ? (childItems ? 'folder-open' : 'folder') : 'apps'} />{' '}
|
||||||
|
<Link href={item.kind === 'folder' ? `/nested-dashboards/f/${item.uid}` : `/d/${item.uid}`}>{item.title}</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{childItems && (
|
||||||
|
<ul style={{ marginLeft: 16 }}>
|
||||||
|
{childItems.length === 0 && (
|
||||||
|
<li>
|
||||||
|
<em>Empty folder</em>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{childItems.map((childItem) => {
|
||||||
|
return (
|
||||||
|
<li key={childItem.uid}>
|
||||||
|
<BrowseItem item={childItem} nestedData={nestedData} onFolderClick={onFolderClick} />{' '}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { parseRouteParams } from 'app/features/search/utils';
|
||||||
|
|
||||||
|
interface SearchViewProps {
|
||||||
|
searchState: ReturnType<typeof parseRouteParams>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchView({ searchState }: SearchViewProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>SearchView</p>
|
||||||
|
|
||||||
|
<pre>{JSON.stringify(searchState, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export const DashboardListPage = memo(({ match, location }: Props) => {
|
|||||||
const { loading, value } = useAsync<() => Promise<{ folder?: FolderDTO; pageNav?: NavModelItem }>>(() => {
|
const { loading, value } = useAsync<() => Promise<{ folder?: FolderDTO; pageNav?: NavModelItem }>>(() => {
|
||||||
const uid = match.params.uid;
|
const uid = match.params.uid;
|
||||||
const url = location.pathname;
|
const url = location.pathname;
|
||||||
|
|
||||||
if (!uid || !url.startsWith('/dashboards')) {
|
if (!uid || !url.startsWith('/dashboards')) {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import { getGrafanaSearcher } from './searcher';
|
|||||||
import { NestedFolderDTO } from './types';
|
import { NestedFolderDTO } from './types';
|
||||||
import { queryResultToViewItem } from './utils';
|
import { queryResultToViewItem } from './utils';
|
||||||
|
|
||||||
export async function getFolderChildren(parentUid?: string, parentTitle?: string): Promise<DashboardViewItem[]> {
|
export async function getFolderChildren(
|
||||||
|
parentUid?: string,
|
||||||
|
parentTitle?: string,
|
||||||
|
dashboardsAtRoot = false
|
||||||
|
): Promise<DashboardViewItem[]> {
|
||||||
if (!config.featureToggles.nestedFolders) {
|
if (!config.featureToggles.nestedFolders) {
|
||||||
console.error('getFolderChildren requires nestedFolders feature toggle');
|
console.error('getFolderChildren requires nestedFolders feature toggle');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parentUid) {
|
if (!dashboardsAtRoot && !parentUid) {
|
||||||
// We don't show dashboards at root in folder view yet - they're shown under a dummy 'general'
|
// We don't show dashboards at root in folder view yet - they're shown under a dummy 'general'
|
||||||
// folder that FolderView adds in
|
// folder that FolderView adds in
|
||||||
const folders = await getChildFolders();
|
const folders = await getChildFolders();
|
||||||
@@ -24,7 +28,7 @@ export async function getFolderChildren(parentUid?: string, parentTitle?: string
|
|||||||
const dashboardsResults = await searcher.search({
|
const dashboardsResults = await searcher.search({
|
||||||
kind: ['dashboard'],
|
kind: ['dashboard'],
|
||||||
query: '*',
|
query: '*',
|
||||||
location: parentUid,
|
location: parentUid ?? 'general',
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { isTruthy } from '@grafana/data';
|
||||||
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
|
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
|
||||||
import { ErrorPage } from 'app/core/components/ErrorPage/ErrorPage';
|
import { ErrorPage } from 'app/core/components/ErrorPage/ErrorPage';
|
||||||
import { LoginPage } from 'app/core/components/Login/LoginPage';
|
import { LoginPage } from 'app/core/components/Login/LoginPage';
|
||||||
@@ -168,6 +169,9 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
...(config.featureToggles.nestedFolders ? getNestedFoldersRoutes() : []),
|
||||||
|
|
||||||
{
|
{
|
||||||
path: '/dashboards',
|
path: '/dashboards',
|
||||||
component: SafeDynamicImport(
|
component: SafeDynamicImport(
|
||||||
@@ -513,7 +517,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
},
|
},
|
||||||
// TODO[Router]
|
// TODO[Router]
|
||||||
// ...playlistRoutes,
|
// ...playlistRoutes,
|
||||||
];
|
].filter(isTruthy);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] {
|
export function getSupportBundleRoutes(cfg = config): RouteDescriptor[] {
|
||||||
@@ -565,3 +569,22 @@ export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNestedFoldersRoutes(): RouteDescriptor[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: '/nested-dashboards',
|
||||||
|
component: SafeDynamicImport(() => import('app/features/browse-dashboards/BrowseDashboardsPage')),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/nested-dashboards/f/:uid',
|
||||||
|
component: SafeDynamicImport(() => import('app/features/browse-dashboards/BrowseDashboardsPage')),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
path: '/nested-dashboards/f/:uid/:slug',
|
||||||
|
component: SafeDynamicImport(() => import('app/features/browse-dashboards/BrowseDashboardsPage')),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit';
|
import { configureStore as reduxConfigureStore } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||||
import { StoreState } from 'app/types/store';
|
import { StoreState } from 'app/types/store';
|
||||||
|
|
||||||
@@ -22,7 +23,8 @@ export function configureStore(initialState?: Partial<StoreState>) {
|
|||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
|
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
|
||||||
alertingApi.middleware,
|
alertingApi.middleware,
|
||||||
publicDashboardApi.middleware
|
publicDashboardApi.middleware,
|
||||||
|
browseDashboardsAPI.middleware
|
||||||
),
|
),
|
||||||
devTools: process.env.NODE_ENV !== 'production',
|
devTools: process.env.NODE_ENV !== 'production',
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
|
|||||||
Reference in New Issue
Block a user