grafana/public/app/features/search/page/components/FolderSection.tsx
Josh Hunt d13488a435
NestedFolders: Show nested folders in Browse folder view (#63746)
* dirty dirty code for showing nested folders in folder view

refactor to NestedFolderItem

Update dashboard grid view to new types

update tests

REBASE OUT OF THIS BRANCH - joshhunt/star-by-uid merged into this

Squashed commit of the following:

commit d0f046ccd3
Author: joshhunt <josh@trtr.co>
Date:   Wed Feb 8 18:35:56 2023 +0000

    undo async

commit abe2777a1f
Author: joshhunt <josh@trtr.co>
Date:   Wed Feb 8 18:34:11 2023 +0000

    Dashboards: Star dashboards by UID

add type for dashboard search dto

clean DashboardSearchItem type

simplify DashboardSearchHit type

remove unused properties from DashboardSearchHit

make uid non-optional

rename + move NestedFolderItem type to DashboardViewItem

clean up

* wip

* fix checkbox selection of nested folders

* show folder's parent correctly

* Add dashboard result kind

* don't render folder empty view in SearchView

* call nested folders api only if feature flag enabled

* remove unused import

* un-rename variable to reduce PR diff

* fix typo in comment

* fix order of pseudoFolders

* Fix General folder not showing in browse

* rename folder view tests

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
2023-03-23 13:28:45 +00:00

237 lines
6.7 KiB
TypeScript

import { css } from '@emotion/css';
import React, { useCallback } from 'react';
import { useAsync, useLocalStorage } from 'react-use';
import { GrafanaTheme2, toIconName } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Card, Checkbox, CollapsableSection, Icon, Spinner, useStyles2 } from '@grafana/ui';
import { config } from 'app/core/config';
import { t } from 'app/core/internationalization';
import { getSectionStorageKey } from 'app/features/search/utils';
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
import { SearchItem } from '../..';
import { GENERAL_FOLDER_UID } from '../../constants';
import { getGrafanaSearcher } from '../../service';
import { getFolderChildren } from '../../service/folders';
import { queryResultToViewItem } from '../../service/utils';
import { DashboardViewItem } from '../../types';
import { SelectionChecker, SelectionToggle } from '../selection';
interface SectionHeaderProps {
selection?: SelectionChecker;
selectionToggle?: SelectionToggle;
onClickItem?: (e: React.MouseEvent<HTMLElement>) => void;
onTagSelected: (tag: string) => void;
section: DashboardViewItem;
renderStandaloneBody?: boolean; // render the body on its own
tags?: string[];
}
async function getChildren(section: DashboardViewItem, tags: string[] | undefined): Promise<DashboardViewItem[]> {
if (config.featureToggles.nestedFolders) {
return getFolderChildren(section.uid, section.title);
}
const query = section.itemsUIDs
? {
uid: section.itemsUIDs,
}
: {
query: '*',
kind: ['dashboard'],
location: section.uid,
sort: 'name_sort',
limit: 1000, // this component does not have infinite scroll, so we need to load everything upfront
};
const raw = await getGrafanaSearcher().search({ ...query, tags });
return raw.view.map((v) => queryResultToViewItem(v, raw.view));
}
export const FolderSection = ({
section,
selectionToggle,
onClickItem,
onTagSelected,
selection,
renderStandaloneBody,
tags,
}: SectionHeaderProps) => {
const editable = selectionToggle != null;
const styles = useStyles2(useCallback((theme: GrafanaTheme2) => getSectionHeaderStyles(theme, editable), [editable]));
const [sectionExpanded, setSectionExpanded] = useLocalStorage(getSectionStorageKey(section.title), false);
const results = useAsync(async () => {
if (!sectionExpanded && !renderStandaloneBody) {
return Promise.resolve([]);
}
const childItems = getChildren(section, tags);
return childItems;
}, [sectionExpanded, tags]);
const onSectionExpand = () => {
setSectionExpanded(!sectionExpanded);
};
const onToggleFolder = (evt: React.FormEvent) => {
evt.preventDefault();
evt.stopPropagation();
if (selectionToggle && selection) {
const checked = !selection(section.kind, section.uid);
selectionToggle(section.kind, section.uid);
const sub = results.value ?? [];
for (const item of sub) {
if (selection(item.kind, item.uid!) !== checked) {
selectionToggle(item.kind, item.uid!);
}
}
}
};
const id = useUniqueId();
const labelId = `section-header-label-${id}`;
let icon = toIconName(section.icon ?? '');
if (!icon) {
icon = sectionExpanded ? 'folder-open' : 'folder';
}
const renderResults = () => {
if (!results.value) {
return null;
} else if (results.value.length === 0 && !results.loading) {
return (
<Card>
<Card.Heading>No results found</Card.Heading>
</Card>
);
}
return results.value.map((item) => {
return (
<SearchItem
key={item.uid}
item={item}
onTagSelected={onTagSelected}
onToggleChecked={(item) => selectionToggle?.(item.kind, item.uid)}
editable={Boolean(selection != null)}
onClickItem={onClickItem}
isSelected={selection?.(item.kind, item.uid)}
/>
);
});
};
// Skip the folder wrapper
if (renderStandaloneBody) {
return (
<div className={styles.folderViewResults}>
{!results.value?.length && results.loading ? <Spinner className={styles.spinner} /> : renderResults()}
</div>
);
}
return (
<CollapsableSection
headerDataTestId={selectors.components.Search.folderHeader(section.title)}
contentDataTestId={selectors.components.Search.folderContent(section.title)}
isOpen={sectionExpanded ?? false}
onToggle={onSectionExpand}
className={styles.wrapper}
contentClassName={styles.content}
loading={results.loading}
labelId={labelId}
label={
<>
{selectionToggle && selection && (
<div onClick={onToggleFolder}>
<Checkbox
value={selection(section.kind, section.uid)}
aria-label={t('search.folder-view.select-folder', 'Select folder')}
/>
</div>
)}
<div className={styles.icon}>
<Icon name={icon} />
</div>
<div className={styles.text}>
<span id={labelId}>{section.title}</span>
{section.url && section.uid !== GENERAL_FOLDER_UID && (
<a href={section.url} className={styles.link}>
<span className={styles.separator}>|</span> <Icon name="folder-upload" />{' '}
{t('search.folder-view.go-to-folder', 'Go to folder')}
</a>
)}
</div>
</>
}
>
{results.value && <ul className={styles.sectionItems}>{renderResults()}</ul>}
</CollapsableSection>
);
};
const getSectionHeaderStyles = (theme: GrafanaTheme2, editable: boolean) => {
const sm = theme.spacing(1);
return {
wrapper: css`
align-items: center;
font-size: ${theme.typography.size.base};
padding: 12px;
border-bottom: none;
color: ${theme.colors.text.secondary};
z-index: 1;
&:hover,
&.selected {
color: ${theme.colors.text};
}
&:hover,
&:focus-visible,
&:focus-within {
a {
opacity: 1;
}
}
`,
sectionItems: css`
margin: 0 24px 0 32px;
`,
icon: css`
padding: 0 ${sm} 0 ${editable ? 0 : sm};
`,
folderViewResults: css`
overflow: auto;
`,
text: css`
flex-grow: 1;
line-height: 24px;
`,
link: css`
padding: 2px 10px 0;
color: ${theme.colors.text.secondary};
opacity: 0;
transition: opacity 150ms ease-in-out;
`,
separator: css`
margin-right: 6px;
`,
content: css`
padding-top: 0px;
padding-bottom: 0px;
`,
spinner: css`
display: grid;
place-content: center;
padding-bottom: 1rem;
`,
};
};