mirror of https://github.com/grafana/grafana
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>pull/70817/head
parent
0668fcdf95
commit
f18a7f7d96
|
@ -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> |
||||
); |
||||
} |
||||
@ -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 }; |
||||
Loading…
Reference in new issue