NestedFolders: Nested folder picker (#70148)

* Initial layout

* Add some styles

* Add checkbox functionality

* Add feature flag

* Extract list component

* remove feature flag

* expand folders

* Don't show empty folder indicators in nested folder picker, prevent opening folder from selecting that folder

* remove legend and button

* selection stuff

* new feature flag just for nested folder picker

* fix lint

* cleanup

* fix movemodal not showing selected item

* refactor styles, make only label clickable

---------

Co-authored-by: Tobias Skarhed <tobias.skarhed@gmail.com>
This commit is contained in:
Josh Hunt 2023-06-28 10:40:29 +01:00 committed by GitHub
parent 0668fcdf95
commit f18a7f7d96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 348 additions and 27 deletions

View File

@ -90,6 +90,7 @@ Experimental features might be changed or removed without prior notice.
| `athenaAsyncQueryDataSupport` | Enable async query data support for Athena |
| `showDashboardValidationWarnings` | Show warnings when dashboards do not validate against the schema |
| `mysqlAnsiQuotes` | Use double quotes to escape keyword in a MySQL query |
| `nestedFolderPicker` | Enables the still in-development new folder picker to support nested folders |
| `showTraceId` | Show trace ids for requests |
| `alertingBacktesting` | Rule backtesting API for alerting |
| `editPanelCSVDragAndDrop` | Enables drag and drop for CSV and Excel files |

View File

@ -56,6 +56,7 @@ export interface FeatureToggles {
mysqlAnsiQuotes?: boolean;
accessControlOnCall?: boolean;
nestedFolders?: boolean;
nestedFolderPicker?: boolean;
accessTokenExpirationCheck?: boolean;
showTraceId?: boolean;
emptyDashboardPage?: boolean;

View File

@ -259,6 +259,12 @@ var (
Stage: FeatureStagePublicPreview,
Owner: grafanaBackendPlatformSquad,
},
{
Name: "nestedFolderPicker",
Description: "Enables the still in-development new folder picker to support nested folders",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
},
{
Name: "accessTokenExpirationCheck",
Description: "Enable OAuth access_token expiration check and token refresh using the refresh_token",

View File

@ -37,6 +37,7 @@ showDashboardValidationWarnings,experimental,@grafana/dashboards-squad,false,fal
mysqlAnsiQuotes,experimental,@grafana/backend-platform,false,false,false,false
accessControlOnCall,preview,@grafana/grafana-authnz-team,false,false,false,false
nestedFolders,preview,@grafana/backend-platform,false,false,false,false
nestedFolderPicker,experimental,@grafana/grafana-frontend-platform,false,false,false,false
accessTokenExpirationCheck,GA,@grafana/grafana-authnz-team,false,false,false,false
showTraceId,experimental,@grafana/observability-logs,false,false,false,false
emptyDashboardPage,GA,@grafana/dashboards-squad,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
37 mysqlAnsiQuotes experimental @grafana/backend-platform false false false false
38 accessControlOnCall preview @grafana/grafana-authnz-team false false false false
39 nestedFolders preview @grafana/backend-platform false false false false
40 nestedFolderPicker experimental @grafana/grafana-frontend-platform false false false false
41 accessTokenExpirationCheck GA @grafana/grafana-authnz-team false false false false
42 showTraceId experimental @grafana/observability-logs false false false false
43 emptyDashboardPage GA @grafana/dashboards-squad false false false true

View File

@ -159,6 +159,10 @@ const (
// Enable folder nesting
FlagNestedFolders = "nestedFolders"
// FlagNestedFolderPicker
// Enables the still in-development new folder picker to support nested folders
FlagNestedFolderPicker = "nestedFolderPicker"
// FlagAccessTokenExpirationCheck
// Enable OAuth access_token expiration check and token refresh using the refresh_token
FlagAccessTokenExpirationCheck = "accessTokenExpirationCheck"

View File

@ -0,0 +1,166 @@
import { css } from '@emotion/css';
import React, { useCallback, useId, useMemo } from 'react';
import { FixedSizeList as List } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2 } from '@grafana/ui';
import { Indent } from 'app/features/browse-dashboards/components/Indent';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
import { FolderUID } from './types';
const ROW_HEIGHT = 40;
const LIST_HEIGHT = ROW_HEIGHT * 6.5; // show 6 and a bit rows
interface NestedFolderListProps {
items: DashboardsTreeItem[];
selectedFolder: FolderUID | undefined;
onFolderClick: (uid: string, newOpenState: boolean) => void;
onSelectionChange: (event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => void;
}
export function NestedFolderList({ items, selectedFolder, onFolderClick, onSelectionChange }: NestedFolderListProps) {
const styles = useStyles2(getStyles);
const virtualData = useMemo(
(): VirtualData => ({ items, selectedFolder, onFolderClick, onSelectionChange }),
[items, selectedFolder, onFolderClick, onSelectionChange]
);
return (
<>
<p className={styles.headerRow}>Name</p>
<List height={LIST_HEIGHT} width="100%" itemData={virtualData} itemSize={ROW_HEIGHT} itemCount={items.length}>
{Row}
</List>
</>
);
}
interface VirtualData extends NestedFolderListProps {}
interface RowProps {
index: number;
style: React.CSSProperties;
data: VirtualData;
}
function Row({ index, style: virtualStyles, data }: RowProps) {
const { items, selectedFolder, onFolderClick, onSelectionChange } = data;
const { item, isOpen, level } = items[index];
const id = useId() + `-uid-${item.uid}`;
const styles = useStyles2(getStyles);
const handleClick = useCallback(
(ev: React.MouseEvent<HTMLButtonElement>) => {
ev.preventDefault();
onFolderClick(item.uid, !isOpen);
},
[item.uid, isOpen, onFolderClick]
);
const handleRadioChange = useCallback(
(ev: React.FormEvent<HTMLInputElement>) => {
if (item.kind === 'folder') {
onSelectionChange(ev, item);
}
},
[item, onSelectionChange]
);
if (item.kind !== 'folder') {
return process.env.NODE_ENV !== 'production' ? <span>Non-folder item</span> : null;
}
return (
<div style={virtualStyles} className={styles.row}>
<input
className={styles.radio}
type="radio"
value={id}
id={id}
name="folder"
checked={item.uid === selectedFolder}
onChange={handleRadioChange}
/>
<div className={styles.rowBody}>
<Indent level={level} />
<IconButton
onClick={handleClick}
aria-label={isOpen ? 'Collapse folder' : 'Expand folder'}
name={isOpen ? 'angle-down' : 'angle-right'}
/>
<label className={styles.label} htmlFor={id}>
<span>{item.title}</span>
</label>
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const rowBody = css({
height: ROW_HEIGHT,
display: 'flex',
position: 'relative',
alignItems: 'center',
flexGrow: 1,
paddingLeft: theme.spacing(1),
});
return {
headerRow: css({
backgroundColor: theme.colors.background.secondary,
height: ROW_HEIGHT,
lineHeight: ROW_HEIGHT + 'px',
margin: 0,
paddingLeft: theme.spacing(3),
}),
row: css({
display: 'flex',
position: 'relative',
alignItems: 'center',
borderBottom: `solid 1px ${theme.colors.border.weak}`,
}),
radio: css({
position: 'absolute',
left: '-1000rem',
'&:checked': {
border: '1px solid green',
},
[`&:checked + .${rowBody}`]: {
backgroundColor: theme.colors.background.secondary,
'&::before': {
display: 'block',
content: '""',
position: 'absolute',
left: 0,
bottom: 0,
top: 0,
width: 4,
borderRadius: theme.shape.radius.default,
backgroundImage: theme.colors.gradients.brandVertical,
},
},
}),
rowBody,
label: css({
'&:hover': {
textDecoration: 'underline',
cursor: 'pointer',
},
}),
};
};

View File

@ -0,0 +1,102 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { LoadingBar } from '@grafana/ui';
import { listFolders, PAGE_SIZE } from 'app/features/browse-dashboards/api/services';
import { createFlatTree } from 'app/features/browse-dashboards/state';
import { DashboardViewItemCollection } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
import { NestedFolderList } from './NestedFolderList';
import { FolderChange, FolderUID } from './types';
async function fetchRootFolders() {
return await listFolders(undefined, undefined, 1, PAGE_SIZE);
}
interface NestedFolderPickerProps {
value?: FolderUID | undefined;
// TODO: think properly (and pragmatically) about how to communicate moving to general folder,
// vs removing selection (if possible?)
onChange?: (folderUID: FolderChange) => void;
}
export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps) {
// const [search, setSearch] = useState('');
const [folderOpenState, setFolderOpenState] = useState<Record<string, boolean>>({});
const [childrenForUID, setChildrenForUID] = useState<Record<string, DashboardViewItem[]>>({});
const state = useAsync(fetchRootFolders);
const handleFolderClick = useCallback(async (uid: string, newOpenState: boolean) => {
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
if (newOpenState) {
const folders = await listFolders(uid, undefined, 1, PAGE_SIZE);
setChildrenForUID((old) => ({ ...old, [uid]: folders }));
}
}, []);
const flatTree = useMemo(() => {
const rootCollection: DashboardViewItemCollection = {
isFullyLoaded: !state.loading,
lastKindHasMoreItems: false,
lastFetchedKind: 'folder',
lastFetchedPage: 1,
items: state.value ?? [],
};
const childrenCollections: Record<string, DashboardViewItemCollection | undefined> = {};
for (const parentUID in childrenForUID) {
const children = childrenForUID[parentUID];
childrenCollections[parentUID] = {
isFullyLoaded: !!children,
lastKindHasMoreItems: false,
lastFetchedKind: 'folder',
lastFetchedPage: 1,
items: children,
};
}
const result = createFlatTree(undefined, rootCollection, childrenCollections, folderOpenState, 0, false);
result.unshift({
isOpen: false,
level: 0,
item: {
kind: 'folder',
title: 'Dashboards',
uid: '',
},
});
return result;
}, [childrenForUID, folderOpenState, state.loading, state.value]);
const handleSelectionChange = useCallback(
(event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => {
console.log('selected', item);
if (onChange) {
onChange({ title: item.title, uid: item.uid });
}
},
[onChange]
);
return (
<fieldset>
{/* <FilterInput placeholder="Search folder" value={search} escapeRegex={false} onChange={(val) => setSearch(val)} /> */}
{state.loading && <LoadingBar width={300} />}
{state.error && <p>{state.error.message}</p>}
{state.value && (
<NestedFolderList
items={flatTree}
selectedFolder={value}
onFolderClick={handleFolderClick}
onSelectionChange={handleSelectionChange}
/>
)}
</fieldset>
);
}

View File

@ -0,0 +1,4 @@
export const ROOT_FOLDER: unique symbol = Symbol('Root folder');
export type FolderUID = string | typeof ROOT_FOLDER;
export type FolderChange = { title: string; uid: FolderUID };

View File

@ -1,8 +1,11 @@
import React, { useState } from 'react';
import { Space } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { Alert, Button, Field, Modal } from '@grafana/ui';
import { P } from '@grafana/ui/src/unstable';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { FolderChange, ROOT_FOLDER } from 'app/core/components/NestedFolderPicker/types';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { DashboardTreeSelection } from '../../types';
@ -21,6 +24,10 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
const [isMoving, setIsMoving] = useState(false);
const selectedFolders = Object.keys(selectedItems.folder).filter((uid) => selectedItems.folder[uid]);
const handleFolderChange = (newFolder: FolderChange) => {
setMoveTarget(newFolder.uid === ROOT_FOLDER ? '' : newFolder.uid);
};
const onMove = async () => {
if (moveTarget !== undefined) {
setIsMoving(true);
@ -45,7 +52,11 @@ export const MoveModal = ({ onConfirm, onDismiss, selectedItems, ...props }: Pro
<Space v={3} />
<Field label="Folder name">
<FolderPicker allowEmpty onChange={({ uid }) => setMoveTarget(uid)} />
{config.featureToggles.nestedFolderPicker ? (
<NestedFolderPicker value={moveTarget} onChange={handleFolderChange} />
) : (
<FolderPicker allowEmpty onChange={handleFolderChange} />
)}
</Field>
<Modal.ButtonRow>

View File

@ -129,19 +129,27 @@ export function useLoadNextChildrenPage() {
* @param openFolders Object of UID to whether that item is expanded or not
* @param level level of item in the tree. Only to be specified when called recursively.
*/
function createFlatTree(
export function createFlatTree(
folderUID: string | undefined,
rootCollection: BrowseDashboardsState['rootItems'],
childrenByUID: BrowseDashboardsState['childrenByParentUID'],
openFolders: Record<string, boolean>,
level = 0
level = 0,
insertEmptyFolderIndicator = true
): DashboardsTreeItem[] {
function mapItem(item: DashboardViewItem, parentUID: string | undefined, level: number): DashboardsTreeItem[] {
const mappedChildren = createFlatTree(item.uid, rootCollection, childrenByUID, openFolders, level + 1);
const mappedChildren = createFlatTree(
item.uid,
rootCollection,
childrenByUID,
openFolders,
level + 1,
insertEmptyFolderIndicator
);
const isOpen = Boolean(openFolders[item.uid]);
const emptyFolder = childrenByUID[item.uid]?.items.length === 0;
if (isOpen && emptyFolder) {
if (isOpen && emptyFolder && insertEmptyFolderIndicator) {
mappedChildren.push({
isOpen: false,
level: level + 1,
@ -168,7 +176,9 @@ function createFlatTree(
? isOpen && collection?.items // keep seperate lines
: collection?.items;
let children = (items || []).flatMap((item) => mapItem(item, folderUID, level));
let children = (items || []).flatMap((item) => {
return mapItem(item, folderUID, level);
});
if ((level === 0 && !collection) || (isOpen && collection && !collection.isFullyLoaded)) {
children = children.concat(getPaginationPlaceholders(PAGE_SIZE, folderUID, level));

View File

@ -2,7 +2,10 @@ import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { TimeZone } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CollapsableSection, Field, Input, RadioButtonGroup, TagsInput } from '@grafana/ui';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { FolderChange, ROOT_FOLDER } from 'app/core/components/NestedFolderPicker/types';
import { Page } from 'app/core/components/Page/Page';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions';
@ -28,10 +31,11 @@ export function GeneralSettingsUnconnected({
}: Props): JSX.Element {
const [renderCounter, setRenderCounter] = useState(0);
const onFolderChange = (folder: { uid: string; title: string }) => {
dashboard.meta.folderUid = folder.uid;
dashboard.meta.folderTitle = folder.title;
const onFolderChange = (newFolder: FolderChange) => {
dashboard.meta.folderUid = newFolder.uid === ROOT_FOLDER ? '' : newFolder.uid;
dashboard.meta.folderTitle = newFolder.title;
dashboard.meta.hasUnsavedFolderChange = true;
setRenderCounter(renderCounter + 1);
};
const onBlur = (event: React.FocusEvent<HTMLInputElement>) => {
@ -103,16 +107,21 @@ export function GeneralSettingsUnconnected({
<Field label="Tags">
<TagsInput id="tags-input" tags={dashboard.tags} onChange={onTagsChange} width={40} />
</Field>
<Field label="Folder">
<FolderPicker
inputId="dashboard-folder-input"
initialTitle={dashboard.meta.folderTitle}
initialFolderUid={dashboard.meta.folderUid}
onChange={onFolderChange}
enableCreateNew={true}
dashboardId={dashboard.id}
skipInitialLoad={true}
/>
{config.featureToggles.nestedFolderPicker ? (
<NestedFolderPicker value={dashboard.meta.folderUid} onChange={onFolderChange} />
) : (
<FolderPicker
inputId="dashboard-folder-input"
initialTitle={dashboard.meta.folderTitle}
initialFolderUid={dashboard.meta.folderUid}
onChange={onFolderChange}
enableCreateNew={true}
dashboardId={dashboard.id}
skipInitialLoad={true}
/>
)}
</Field>
<Field

View File

@ -1,6 +1,8 @@
import React from 'react';
import { config } from '@grafana/runtime';
import { Button, Input, Switch, Form, Field, InputControl, HorizontalGroup } from '@grafana/ui';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
@ -101,15 +103,19 @@ export const SaveDashboardAsForm = ({ dashboard, isNew, onSubmit, onCancel, onSu
</Field>
<Field label="Folder">
<InputControl
render={({ field: { ref, ...field } }) => (
<FolderPicker
{...field}
dashboardId={dashboard.id}
initialFolderUid={dashboard.meta.folderUid}
initialTitle={dashboard.meta.folderTitle}
enableCreateNew
/>
)}
render={({ field: { ref, ...field } }) =>
config.featureToggles.nestedFolderPicker ? (
<NestedFolderPicker {...field} value={field.value?.uid} />
) : (
<FolderPicker
{...field}
dashboardId={dashboard.id}
initialFolderUid={dashboard.meta.folderUid}
initialTitle={dashboard.meta.folderTitle}
enableCreateNew
/>
)
}
control={control}
name="$folder"
/>