Nested folders: Unify visual styles between tree + search view (#70814)

* unify search table styles with browse

* add a skeleton state when switching to search view

* show all column headers

* use isItemLoaded

* extract number of placeholder rows into variable + add comment

* fix all selection toggle behaviour

* tidy up

* fix unit test
This commit is contained in:
Ashley Harrison 2023-06-29 10:31:22 +01:00 committed by GitHub
parent 0bbf011ca8
commit 703bf4afcc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 172 additions and 147 deletions

View File

@ -38,8 +38,8 @@ interface DashboardsTreeProps {
requestLoadMore: (folderUid: string | undefined) => void; requestLoadMore: (folderUid: string | undefined) => void;
} }
const HEADER_HEIGHT = 35; const HEADER_HEIGHT = 36;
const ROW_HEIGHT = 35; const ROW_HEIGHT = 36;
export function DashboardsTree({ export function DashboardsTree({
items, items,

View File

@ -1,6 +1,7 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Button, Card, Spinner } from '@grafana/ui'; import { DataFrameView, toDataFrame } from '@grafana/data';
import { Button, Card } from '@grafana/ui';
import { Trans } from 'app/core/internationalization'; import { Trans } from 'app/core/internationalization';
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
import { SearchResultsProps, SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable'; import { SearchResultsProps, SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable';
@ -16,6 +17,30 @@ interface SearchViewProps {
canSelect: boolean; canSelect: boolean;
} }
const NUM_PLACEHOLDER_ROWS = 50;
const initialLoadingView = {
view: new DataFrameView(
toDataFrame({
fields: [
{ name: 'uid', display: true, values: Array(NUM_PLACEHOLDER_ROWS).fill(null) },
{ name: 'kind', display: true, values: Array(NUM_PLACEHOLDER_ROWS).fill('dashboard') },
{ name: 'name', display: true, values: Array(NUM_PLACEHOLDER_ROWS).fill('') },
{ name: 'location', display: true, values: Array(NUM_PLACEHOLDER_ROWS).fill('') },
{ name: 'tags', display: true, values: Array(NUM_PLACEHOLDER_ROWS).fill([]) },
],
meta: {
custom: {
locationInfo: [],
},
},
})
),
loadMoreItems: () => Promise.resolve(),
// this is key and controls whether to show the skeleton in generateColumns
isItemLoaded: () => false,
totalRows: NUM_PLACEHOLDER_ROWS,
};
export function SearchView({ width, height, canSelect }: SearchViewProps) { export function SearchView({ width, height, canSelect }: SearchViewProps) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const selectedItems = useSelector((wholeState) => wholeState.browseDashboards.selectedItems); const selectedItems = useSelector((wholeState) => wholeState.browseDashboards.selectedItems);
@ -24,7 +49,7 @@ export function SearchView({ width, height, canSelect }: SearchViewProps) {
const { keyboardEvents } = useKeyNavigationListener(); const { keyboardEvents } = useKeyNavigationListener();
const [searchState, stateManager] = useSearchStateManager(); const [searchState, stateManager] = useSearchStateManager();
const value = searchState.result; const value = searchState.result ?? initialLoadingView;
const selectionChecker = useCallback( const selectionChecker = useCallback(
(kind: string | undefined, uid: string): boolean => { (kind: string | undefined, uid: string): boolean => {
@ -61,14 +86,6 @@ export function SearchView({ width, height, canSelect }: SearchViewProps) {
[selectionChecker, dispatch] [selectionChecker, dispatch]
); );
if (!value) {
return (
<div style={{ width }}>
<Spinner />
</div>
);
}
if (value.totalRows === 0) { if (value.totalRows === 0) {
return ( return (
<div style={{ width }}> <div style={{ width }}>

View File

@ -43,7 +43,7 @@ describe('SearchResultsTable', () => {
}); });
const mockSearchResult: QueryResponse = { const mockSearchResult: QueryResponse = {
isItemLoaded: jest.fn(), isItemLoaded: jest.fn().mockReturnValue(true),
loadMoreItems: jest.fn(), loadMoreItems: jest.fn(),
totalRows: searchData.length, totalRows: searchData.length,
view: new DataFrameView<DashboardQueryResult>(dataFrames[0]), view: new DataFrameView<DashboardQueryResult>(dataFrames[0]),

View File

@ -1,7 +1,6 @@
/* eslint-disable react/jsx-no-undef */
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { useEffect, useMemo, useRef, useCallback, useState, CSSProperties } from 'react'; import React, { useEffect, useMemo, useRef, useCallback, useState, CSSProperties } from 'react';
import { useTable, Column, TableOptions, Cell, useAbsoluteLayout } from 'react-table'; import { useTable, Column, TableOptions, Cell } from 'react-table';
import { FixedSizeList } from 'react-window'; import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader'; import InfiniteLoader from 'react-window-infinite-loader';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -11,6 +10,7 @@ import { TableCellHeight } from '@grafana/schema';
import { useStyles2, useTheme2 } from '@grafana/ui'; import { useStyles2, useTheme2 } from '@grafana/ui';
import { TableCell } from '@grafana/ui/src/components/Table/TableCell'; import { TableCell } from '@grafana/ui/src/components/Table/TableCell';
import { useTableStyles } from '@grafana/ui/src/components/Table/styles'; import { useTableStyles } from '@grafana/ui/src/components/Table/styles';
import { useCustomFlexLayout } from 'app/features/browse-dashboards/components/customFlexTableLayout';
import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection'; import { useSearchKeyboardNavigation } from '../../hooks/useSearchKeyboardSelection';
import { QueryResponse } from '../../service'; import { QueryResponse } from '../../service';
@ -35,7 +35,7 @@ export type TableColumn = Column & {
field?: Field; field?: Field;
}; };
const HEADER_HEIGHT = 36; // pixels const ROW_HEIGHT = 36; // pixels
export const SearchResultsTable = React.memo( export const SearchResultsTable = React.memo(
({ ({
@ -101,7 +101,7 @@ export const SearchResultsTable = React.memo(
[memoizedColumns, memoizedData] [memoizedColumns, memoizedData]
); );
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useAbsoluteLayout); const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = useTable(options, useCustomFlexLayout);
const handleLoadMore = useCallback( const handleLoadMore = useCallback(
async (startIndex: number, endIndex: number) => { async (startIndex: number, endIndex: number) => {
@ -162,24 +162,24 @@ export const SearchResultsTable = React.memo(
return ( return (
<div {...getTableProps()} aria-label="Search results table" role="table"> <div {...getTableProps()} aria-label="Search results table" role="table">
<div> {headerGroups.map((headerGroup) => {
{headerGroups.map((headerGroup) => { const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps({
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps(); style: { width },
});
return ( return (
<div key={key} {...headerGroupProps} className={styles.headerRow}> <div key={key} {...headerGroupProps} className={styles.headerRow}>
{headerGroup.headers.map((column) => { {headerGroup.headers.map((column) => {
const { key, ...headerProps } = column.getHeaderProps(); const { key, ...headerProps } = column.getHeaderProps();
return ( return (
<div key={key} {...headerProps} role="columnheader" className={styles.headerCell}> <div key={key} {...headerProps} role="columnheader" className={styles.headerCell}>
{column.render('Header')} {column.render('Header')}
</div> </div>
); );
})} })}
</div> </div>
); );
})} })}
</div>
<div {...getTableBodyProps()}> <div {...getTableBodyProps()}>
<InfiniteLoader <InfiniteLoader
@ -195,10 +195,10 @@ export const SearchResultsTable = React.memo(
setListEl(innerRef); setListEl(innerRef);
}} }}
onItemsRendered={onItemsRendered} onItemsRendered={onItemsRendered}
height={height - HEADER_HEIGHT} height={height - ROW_HEIGHT}
itemCount={rows.length} itemCount={rows.length}
itemSize={tableStyles.rowHeight} itemSize={tableStyles.rowHeight}
width="100%" width={width}
style={{ overflow: 'hidden auto' }} style={{ overflow: 'hidden auto' }}
> >
{RenderRow} {RenderRow}
@ -224,18 +224,25 @@ const getStyles = (theme: GrafanaTheme2) => {
height: 100%; height: 100%;
`, `,
headerCell: css` headerCell: css`
align-items: center;
display: flex;
overflo: hidden;
padding: ${theme.spacing(1)}; padding: ${theme.spacing(1)};
`, `,
headerRow: css` headerRow: css`
background-color: ${theme.colors.background.secondary}; background-color: ${theme.colors.background.secondary};
height: ${HEADER_HEIGHT}px; display: flex;
align-items: center; gap: ${theme.spacing(1)};
height: ${ROW_HEIGHT}px;
`, `,
selectedRow: css` selectedRow: css`
background-color: ${rowHoverBg}; background-color: ${rowHoverBg};
box-shadow: inset 3px 0px ${theme.colors.primary.border}; box-shadow: inset 3px 0px ${theme.colors.primary.border};
`, `,
rowContainer: css` rowContainer: css`
display: flex;
gap: ${theme.spacing(1)};
height: ${ROW_HEIGHT}px;
label: row; label: row;
&:hover { &:hover {
background-color: ${rowHoverBg}; background-color: ${rowHoverBg};
@ -253,27 +260,22 @@ const getStyles = (theme: GrafanaTheme2) => {
// CSS for columns from react table // CSS for columns from react table
const getColumnStyles = (theme: GrafanaTheme2) => { const getColumnStyles = (theme: GrafanaTheme2) => {
return { return {
cell: css({
padding: theme.spacing(1),
overflow: 'hidden', // Required so flex children can do text-overflow: ellipsis
display: 'flex',
alignItems: 'center',
}),
nameCellStyle: css` nameCellStyle: css`
border-right: none;
padding: ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(2)};
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
user-select: text; user-select: text;
white-space: nowrap; white-space: nowrap;
&:hover {
box-shadow: none;
}
`, `,
headerNameStyle: css` typeCell: css({
padding-left: ${theme.spacing(1)}; gap: theme.spacing(0.5),
`, }),
typeIcon: css` typeIcon: css`
margin-left: 5px;
margin-right: 9.5px;
vertical-align: middle;
display: inline-block;
margin-bottom: ${theme.v1.spacing.xxs};
fill: ${theme.colors.text.secondary}; fill: ${theme.colors.text.secondary};
`, `,
datasourceItem: css` datasourceItem: css`
@ -291,41 +293,24 @@ const getColumnStyles = (theme: GrafanaTheme2) => {
color: ${theme.colors.error.main}; color: ${theme.colors.error.main};
text-decoration: line-through; text-decoration: line-through;
`, `,
typeText: css` locationContainer: css({
color: ${theme.colors.text.secondary}; display: 'flex',
padding-top: ${theme.spacing(1)}; flexWrap: 'nowrap',
`, gap: theme.spacing(1),
overflow: 'hidden',
}),
locationItem: css` locationItem: css`
align-items: center;
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
margin-right: 12px; display: flex;
`, flex-wrap: nowrap;
sortedHeader: css` gap: 4px;
text-align: right; overflow: hidden;
padding-right: ${theme.spacing(2)};
`,
sortedItems: css`
text-align: right;
padding: ${theme.spacing(1)} ${theme.spacing(3)} ${theme.spacing(1)} ${theme.spacing(1)};
`, `,
explainItem: css` explainItem: css`
text-align: right;
padding: ${theme.spacing(1)} ${theme.spacing(3)} ${theme.spacing(1)} ${theme.spacing(1)};
cursor: pointer; cursor: pointer;
`, `,
locationCellStyle: css`
padding-top: ${theme.spacing(1)};
padding-right: ${theme.spacing(1)};
`,
checkboxHeader: css`
margin-left: 2px;
`,
checkbox: css`
margin-left: 10px;
margin-right: 10px;
margin-top: 5px;
`,
tagList: css` tagList: css`
padding-top: ${theme.spacing(0.5)};
justify-content: flex-start; justify-content: flex-start;
flex-wrap: nowrap; flex-wrap: nowrap;
`, `,

View File

@ -1,5 +1,6 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import React from 'react'; import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { import {
DisplayProcessor, DisplayProcessor,
@ -10,7 +11,8 @@ import {
getFieldDisplayName, getFieldDisplayName,
} from '@grafana/data'; } from '@grafana/data';
import { config, getDataSourceSrv } from '@grafana/runtime'; import { config, getDataSourceSrv } from '@grafana/runtime';
import { Checkbox, Icon, IconButton, IconName, TagList } from '@grafana/ui'; import { Checkbox, Icon, IconName, TagList } from '@grafana/ui';
import { Span } from '@grafana/ui/src/unstable';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { t } from 'app/core/internationalization'; import { t } from 'app/core/internationalization';
import { PluginIconName } from 'app/features/plugins/admin/types'; import { PluginIconName } from 'app/features/plugins/admin/types';
@ -57,38 +59,30 @@ export const generateColumns = (
let width = 50; let width = 50;
if (selection && selectionToggle) { if (selection && selectionToggle) {
width = 30; width = 0;
columns.push({ columns.push({
id: `column-checkbox`, id: `column-checkbox`,
width, width,
Header: () => { Header: () => {
if (selection('*', '*')) {
return (
<div className={styles.checkboxHeader}>
<IconButton name="check-square" onClick={clearSelection} tooltip="Clear selection" />
</div>
);
}
return ( return (
<div className={styles.checkboxHeader}> <Checkbox
<Checkbox indeterminate={selection('*', '*')}
checked={false} checked={false}
onChange={(e) => { disabled={!response}
e.stopPropagation(); onChange={(e) => {
e.preventDefault(); const { view } = response;
const { view } = response; const count = Math.min(view.length, 50);
const count = Math.min(view.length, 50); const hasSelection = selection('*', '*');
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const item = view.get(i); const item = view.get(i);
if (item.uid && item.kind) { if (item.uid && item.kind) {
if (!selection(item.kind, item.uid)) { if (hasSelection === selection(item.kind, item.uid)) {
selectionToggle(item.kind, item.uid); selectionToggle(item.kind, item.uid);
}
} }
} }
}} }
/> }}
</div> />
); );
}, },
Cell: (p) => { Cell: (p) => {
@ -97,16 +91,14 @@ export const generateColumns = (
const selected = selection(kind, uid); const selected = selection(kind, uid);
const hasUID = uid != null; // Panels don't have UID! Likely should not be shown on pages with manage options const hasUID = uid != null; // Panels don't have UID! Likely should not be shown on pages with manage options
return ( return (
<div {...p.cellProps}> <div {...p.cellProps} className={styles.cell}>
<div className={styles.checkbox}> <Checkbox
<Checkbox disabled={!hasUID}
disabled={!hasUID} value={selected && hasUID}
value={selected && hasUID} onChange={(e) => {
onChange={(e) => { selectionToggle(kind, uid);
selectionToggle(kind, uid); }}
}} />
/>
</div>
</div> </div>
); );
}, },
@ -127,20 +119,26 @@ export const generateColumns = (
classNames += ' ' + styles.missingTitleText; classNames += ' ' + styles.missingTitleText;
} }
return ( return (
<a {...p.cellProps} href={p.userProps.href} onClick={p.userProps.onClick} className={classNames} title={name}> <div className={styles.cell} {...p.cellProps}>
{name} {!response.isItemLoaded(p.row.index) ? (
</a> <Skeleton width={200} />
) : (
<a href={p.userProps.href} onClick={p.userProps.onClick} className={classNames} title={name}>
{name}
</a>
)}
</div>
); );
}, },
id: `column-name`, id: `column-name`,
field: access.name!, field: access.name!,
Header: () => <div className={styles.headerNameStyle}>{t('search.results-table.name-header', 'Name')}</div>, Header: () => <div>{t('search.results-table.name-header', 'Name')}</div>,
width, width,
}); });
availableWidth -= width; availableWidth -= width;
width = TYPE_COLUMN_WIDTH; width = TYPE_COLUMN_WIDTH;
columns.push(makeTypeColumn(access.kind, access.panel_type, width, styles)); columns.push(makeTypeColumn(response, access.kind, access.panel_type, width, styles));
availableWidth -= width; availableWidth -= width;
// Show datasources if we have any // Show datasources if we have any
@ -168,20 +166,29 @@ export const generateColumns = (
Cell: (p) => { Cell: (p) => {
const parts = (access.location?.values[p.row.index] ?? '').split('/'); const parts = (access.location?.values[p.row.index] ?? '').split('/');
return ( return (
<div {...p.cellProps} className={cx(styles.locationCellStyle)}> <div {...p.cellProps} className={styles.cell}>
{parts.map((p) => { {!response.isItemLoaded(p.row.index) ? (
let info = meta.locationInfo[p]; <Skeleton width={150} />
if (!info && p === 'general') { ) : (
info = { kind: 'folder', url: '/dashboards', name: 'General' }; <div className={styles.locationContainer}>
} {parts.map((p) => {
return info ? ( let info = meta.locationInfo[p];
<a key={p} href={info.url} className={styles.locationItem}> if (!info && p === 'general') {
<Icon name={getIconForKind(info.kind)} /> {info.name} info = { kind: 'folder', url: '/dashboards', name: 'General' };
</a> }
) : ( return info ? (
<span key={p}>{p}</span> <a key={p} href={info.url} className={styles.locationItem}>
); <Icon name={getIconForKind(info.kind)} />
})} <Span variant="body" truncate>
{info.name}
</Span>
</a>
) : (
<span key={p}>{p}</span>
);
})}
</div>
)}
</div> </div>
); );
}, },
@ -193,17 +200,17 @@ export const generateColumns = (
} }
if (availableWidth > 0 && showTags) { if (availableWidth > 0 && showTags) {
columns.push(makeTagsColumn(access.tags, availableWidth, styles.tagList, onTagSelected)); columns.push(makeTagsColumn(response, access.tags, availableWidth, styles, onTagSelected));
} }
if (sortField && sortFieldWith) { if (sortField && sortFieldWith) {
const disp = sortField.display ?? getDisplayProcessor({ field: sortField, theme: config.theme2 }); const disp = sortField.display ?? getDisplayProcessor({ field: sortField, theme: config.theme2 });
columns.push({ columns.push({
Header: () => <div className={styles.sortedHeader}>{getFieldDisplayName(sortField)}</div>, Header: getFieldDisplayName(sortField),
Cell: (p) => { Cell: (p) => {
return ( return (
<div {...p.cellProps} className={styles.sortedItems}> <div {...p.cellProps} className={styles.cell}>
{getDisplayValue({ {getDisplayValue({
sortField, sortField,
getDisplay: disp, getDisplay: disp,
@ -239,7 +246,11 @@ export const generateColumns = (
Header: () => <div className={styles.sortedHeader}>Score</div>, Header: () => <div className={styles.sortedHeader}>Score</div>,
Cell: (p) => { Cell: (p) => {
return ( return (
<div {...p.cellProps} className={styles.explainItem} onClick={() => showExplainPopup(p.row.index)}> <div
{...p.cellProps}
className={cx(styles.cell, styles.explainItem)}
onClick={() => showExplainPopup(p.row.index)}
>
{vals[p.row.index]} {vals[p.row.index]}
</div> </div>
); );
@ -314,6 +325,7 @@ function makeDataSourceColumn(
} }
function makeTypeColumn( function makeTypeColumn(
response: QueryResponse,
kindField: Field<string>, kindField: Field<string>,
typeField: Field<string>, typeField: Field<string>,
width: number, width: number,
@ -366,9 +378,15 @@ function makeTypeColumn(
} }
} }
return ( return (
<div {...p.cellProps} className={styles.typeText}> <div {...p.cellProps} className={cx(styles.cell, styles.typeCell)}>
<Icon name={icon} size="sm" title={txt} className={styles.typeIcon} /> {!response.isItemLoaded(p.row.index) ? (
{txt} <Skeleton width={100} />
) : (
<>
<Icon name={icon} size="sm" title={txt} className={styles.typeIcon} />
{txt}
</>
)}
</div> </div>
); );
}, },
@ -377,19 +395,24 @@ function makeTypeColumn(
} }
function makeTagsColumn( function makeTagsColumn(
response: QueryResponse,
field: Field<string[]>, field: Field<string[]>,
width: number, width: number,
tagListClass: string, styles: Record<string, string>,
onTagSelected: (tag: string) => void onTagSelected: (tag: string) => void
): TableColumn { ): TableColumn {
return { return {
Cell: (p) => { Cell: (p) => {
const tags = field.values[p.row.index]; const tags = field.values[p.row.index];
return tags ? ( return (
<div {...p.cellProps}> <div {...p.cellProps} className={styles.cell}>
<TagList className={tagListClass} tags={tags} onClick={onTagSelected} /> {!response.isItemLoaded(p.row.index) ? (
<TagList.Skeleton />
) : (
<>{tags ? <TagList className={styles.tagList} tags={tags} onClick={onTagSelected} /> : null}</>
)}
</div> </div>
) : null; );
}, },
id: `column-tags`, id: `column-tags`,
field: field, field: field,