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:
Josh Hunt
2023-04-12 10:44:01 +01:00
committed by GitHub
parent 52f39e6fa0
commit 5df33c0dc1
11 changed files with 282 additions and 5 deletions

1
.github/CODEOWNERS vendored
View File

@@ -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

View File

@@ -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 = {};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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>
);
}

View 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>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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({});
}

View File

@@ -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,
});

View File

@@ -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')),
},
];
}

View File

@@ -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: {