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/sandbox/ @grafana/grafana-frontend-platform
|
||||
/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/serviceaccounts/ @grafana/grafana-authnz-team
|
||||
/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 alertingReducers from 'app/features/alerting/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 panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
||||
import dashboardReducers from 'app/features/dashboard/state/reducers';
|
||||
@@ -48,6 +49,7 @@ const rootReducers = {
|
||||
plugins: pluginsReducer,
|
||||
[alertingApi.reducerPath]: alertingApi.reducer,
|
||||
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
|
||||
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
|
||||
};
|
||||
|
||||
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 uid = match.params.uid;
|
||||
const url = location.pathname;
|
||||
|
||||
if (!uid || !url.startsWith('/dashboards')) {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
@@ -7,13 +7,17 @@ import { getGrafanaSearcher } from './searcher';
|
||||
import { NestedFolderDTO } from './types';
|
||||
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) {
|
||||
console.error('getFolderChildren requires nestedFolders feature toggle');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parentUid) {
|
||||
if (!dashboardsAtRoot && !parentUid) {
|
||||
// We don't show dashboards at root in folder view yet - they're shown under a dummy 'general'
|
||||
// folder that FolderView adds in
|
||||
const folders = await getChildFolders();
|
||||
@@ -24,7 +28,7 @@ export async function getFolderChildren(parentUid?: string, parentTitle?: string
|
||||
const dashboardsResults = await searcher.search({
|
||||
kind: ['dashboard'],
|
||||
query: '*',
|
||||
location: parentUid,
|
||||
location: parentUid ?? 'general',
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
import { isTruthy } from '@grafana/data';
|
||||
import { NavLandingPage } from 'app/core/components/AppChrome/NavLandingPage';
|
||||
import { ErrorPage } from 'app/core/components/ErrorPage/ErrorPage';
|
||||
import { LoginPage } from 'app/core/components/Login/LoginPage';
|
||||
@@ -168,6 +169,9 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
)
|
||||
),
|
||||
},
|
||||
|
||||
...(config.featureToggles.nestedFolders ? getNestedFoldersRoutes() : []),
|
||||
|
||||
{
|
||||
path: '/dashboards',
|
||||
component: SafeDynamicImport(
|
||||
@@ -513,7 +517,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
},
|
||||
// TODO[Router]
|
||||
// ...playlistRoutes,
|
||||
];
|
||||
].filter(isTruthy);
|
||||
}
|
||||
|
||||
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 { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { StoreState } from 'app/types/store';
|
||||
|
||||
@@ -22,7 +23,8 @@ export function configureStore(initialState?: Partial<StoreState>) {
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
|
||||
alertingApi.middleware,
|
||||
publicDashboardApi.middleware
|
||||
publicDashboardApi.middleware,
|
||||
browseDashboardsAPI.middleware
|
||||
),
|
||||
devTools: process.env.NODE_ENV !== 'production',
|
||||
preloadedState: {
|
||||
|
||||
Reference in New Issue
Block a user