mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
0668fcdf95
commit
f18a7f7d96
@ -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 |
|
||||
|
@ -56,6 +56,7 @@ export interface FeatureToggles {
|
||||
mysqlAnsiQuotes?: boolean;
|
||||
accessControlOnCall?: boolean;
|
||||
nestedFolders?: boolean;
|
||||
nestedFolderPicker?: boolean;
|
||||
accessTokenExpirationCheck?: boolean;
|
||||
showTraceId?: boolean;
|
||||
emptyDashboardPage?: boolean;
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
@ -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',
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
4
public/app/core/components/NestedFolderPicker/types.ts
Normal file
4
public/app/core/components/NestedFolderPicker/types.ts
Normal 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 };
|
@ -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>
|
||||
|
@ -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));
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user