From f18a7f7d9696a6e80606dc49d5ac0064384f111d Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Wed, 28 Jun 2023 10:40:29 +0100 Subject: [PATCH] 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 --- .../feature-toggles/index.md | 1 + .../src/types/featureToggles.gen.ts | 1 + pkg/services/featuremgmt/registry.go | 6 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + .../NestedFolderPicker/NestedFolderList.tsx | 166 ++++++++++++++++++ .../NestedFolderPicker/NestedFolderPicker.tsx | 102 +++++++++++ .../components/NestedFolderPicker/types.ts | 4 + .../components/BrowseActions/MoveModal.tsx | 13 +- .../features/browse-dashboards/state/hooks.ts | 20 ++- .../DashboardSettings/GeneralSettings.tsx | 33 ++-- .../forms/SaveDashboardAsForm.tsx | 24 ++- 12 files changed, 348 insertions(+), 27 deletions(-) create mode 100644 public/app/core/components/NestedFolderPicker/NestedFolderList.tsx create mode 100644 public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx create mode 100644 public/app/core/components/NestedFolderPicker/types.ts diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 3c3c137635e..c7f0104bca9 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -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 | diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f6c2fb7dcd8..a0a5e83184d 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -56,6 +56,7 @@ export interface FeatureToggles { mysqlAnsiQuotes?: boolean; accessControlOnCall?: boolean; nestedFolders?: boolean; + nestedFolderPicker?: boolean; accessTokenExpirationCheck?: boolean; showTraceId?: boolean; emptyDashboardPage?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 75244a3d2d2..781bcaef961 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 88818ef74a6..b3701a02b77 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index e552314fc81..420973044ae 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -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" diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx new file mode 100644 index 00000000000..0280f82f90c --- /dev/null +++ b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx @@ -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, 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 ( + <> +

Name

+ + {Row} + + + ); +} + +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) => { + ev.preventDefault(); + onFolderClick(item.uid, !isOpen); + }, + [item.uid, isOpen, onFolderClick] + ); + + const handleRadioChange = useCallback( + (ev: React.FormEvent) => { + if (item.kind === 'folder') { + onSelectionChange(ev, item); + } + }, + [item, onSelectionChange] + ); + + if (item.kind !== 'folder') { + return process.env.NODE_ENV !== 'production' ? Non-folder item : null; + } + + return ( +
+ + +
+ + + + + +
+
+ ); +} + +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', + }, + }), + }; +}; diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx new file mode 100644 index 00000000000..30a25df97d5 --- /dev/null +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -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>({}); + const [childrenForUID, setChildrenForUID] = useState>({}); + 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 = {}; + + 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, item: DashboardViewItem) => { + console.log('selected', item); + if (onChange) { + onChange({ title: item.title, uid: item.uid }); + } + }, + [onChange] + ); + + return ( +
+ {/* setSearch(val)} /> */} + + {state.loading && } + {state.error &&

{state.error.message}

} + {state.value && ( + + )} +
+ ); +} diff --git a/public/app/core/components/NestedFolderPicker/types.ts b/public/app/core/components/NestedFolderPicker/types.ts new file mode 100644 index 00000000000..5eb76761b8e --- /dev/null +++ b/public/app/core/components/NestedFolderPicker/types.ts @@ -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 }; diff --git a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx index 68ad11f64b7..8f6d30efda2 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/MoveModal.tsx @@ -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 - setMoveTarget(uid)} /> + {config.featureToggles.nestedFolderPicker ? ( + + ) : ( + + )} diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 71d0fd6596b..d591281db5d 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -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, - 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)); diff --git a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx index c1f7b4697bf..d5396248772 100644 --- a/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx @@ -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) => { @@ -103,16 +107,21 @@ export function GeneralSettingsUnconnected({ + - + {config.featureToggles.nestedFolderPicker ? ( + + ) : ( + + )} ( - - )} + render={({ field: { ref, ...field } }) => + config.featureToggles.nestedFolderPicker ? ( + + ) : ( + + ) + } control={control} name="$folder" />