mirror of https://github.com/grafana/grafana
Logs Panel: Table UI - Reordering table columns via drag-and-drop (#79536)
* Implement react-beautiful-dnd to facilitate column reordering * Refactoring field display components * Refactoring internal types to better align with Grafana style guide --------- Co-authored-by: Sven Grossmann <sven.grossmann@grafana.com>pull/80649/head
parent
581936a442
commit
7b8db643a3
@ -0,0 +1,109 @@ |
|||||||
|
import { css, cx } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from 'react-beautiful-dnd'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data/src'; |
||||||
|
import { useTheme2 } from '@grafana/ui/src'; |
||||||
|
|
||||||
|
import { LogsTableEmptyFields } from './LogsTableEmptyFields'; |
||||||
|
import { LogsTableNavField } from './LogsTableNavField'; |
||||||
|
import { FieldNameMeta } from './LogsTableWrap'; |
||||||
|
|
||||||
|
export function getLogsFieldsStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
wrap: css({ |
||||||
|
marginTop: theme.spacing(1), |
||||||
|
marginBottom: theme.spacing(1), |
||||||
|
display: 'flex', |
||||||
|
background: theme.colors.background.primary, |
||||||
|
}), |
||||||
|
dragging: css({ |
||||||
|
background: theme.colors.background.secondary, |
||||||
|
}), |
||||||
|
columnWrapper: css({ |
||||||
|
marginBottom: theme.spacing(1.5), |
||||||
|
// need some space or the outline of the checkbox is cut off
|
||||||
|
paddingLeft: theme.spacing(0.5), |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function sortLabels(labels: Record<string, FieldNameMeta>) { |
||||||
|
return (a: string, b: string) => { |
||||||
|
const la = labels[a]; |
||||||
|
const lb = labels[b]; |
||||||
|
|
||||||
|
// Sort by index
|
||||||
|
if (la.index != null && lb.index != null) { |
||||||
|
return la.index - lb.index; |
||||||
|
} |
||||||
|
|
||||||
|
// otherwise do not sort
|
||||||
|
return 0; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export const LogsTableActiveFields = (props: { |
||||||
|
labels: Record<string, FieldNameMeta>; |
||||||
|
valueFilter: (value: string) => boolean; |
||||||
|
toggleColumn: (columnName: string) => void; |
||||||
|
reorderColumn: (sourceIndex: number, destinationIndex: number) => void; |
||||||
|
id: string; |
||||||
|
}): JSX.Element => { |
||||||
|
const { reorderColumn, labels, valueFilter, toggleColumn } = props; |
||||||
|
const theme = useTheme2(); |
||||||
|
const styles = getLogsFieldsStyles(theme); |
||||||
|
const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); |
||||||
|
|
||||||
|
const onDragEnd = (result: DropResult) => { |
||||||
|
if (!result.destination) { |
||||||
|
return; |
||||||
|
} |
||||||
|
reorderColumn(result.source.index, result.destination.index); |
||||||
|
}; |
||||||
|
|
||||||
|
const renderTitle = (labelName: string) => { |
||||||
|
const label = labels[labelName]; |
||||||
|
if (label) { |
||||||
|
return `${labelName} appears in ${label?.percentOfLinesWithLabel}% of log lines`; |
||||||
|
} |
||||||
|
|
||||||
|
return undefined; |
||||||
|
}; |
||||||
|
|
||||||
|
if (labelKeys.length) { |
||||||
|
return ( |
||||||
|
<DragDropContext onDragEnd={onDragEnd}> |
||||||
|
<Droppable droppableId="order-fields" direction="vertical"> |
||||||
|
{(provided) => ( |
||||||
|
<div className={styles.columnWrapper} {...provided.droppableProps} ref={provided.innerRef}> |
||||||
|
{labelKeys.sort(sortLabels(labels)).map((labelName, index) => ( |
||||||
|
<Draggable draggableId={labelName} key={labelName} index={index}> |
||||||
|
{(provided: DraggableProvided, snapshot) => ( |
||||||
|
<div |
||||||
|
className={cx(styles.wrap, snapshot.isDragging ? styles.dragging : undefined)} |
||||||
|
ref={provided.innerRef} |
||||||
|
{...provided.draggableProps} |
||||||
|
{...provided.dragHandleProps} |
||||||
|
title={renderTitle(labelName)} |
||||||
|
> |
||||||
|
<LogsTableNavField |
||||||
|
label={labelName} |
||||||
|
onChange={() => toggleColumn(labelName)} |
||||||
|
labels={labels} |
||||||
|
draggable={true} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</Draggable> |
||||||
|
))} |
||||||
|
{provided.placeholder} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</Droppable> |
||||||
|
</DragDropContext> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return <LogsTableEmptyFields />; |
||||||
|
}; |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { useTheme2 } from '@grafana/ui/src'; |
||||||
|
|
||||||
|
import { getLogsFieldsStyles } from './LogsTableActiveFields'; |
||||||
|
import { LogsTableEmptyFields } from './LogsTableEmptyFields'; |
||||||
|
import { LogsTableNavField } from './LogsTableNavField'; |
||||||
|
import { FieldNameMeta } from './LogsTableWrap'; |
||||||
|
|
||||||
|
const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); |
||||||
|
|
||||||
|
function sortLabels(labels: Record<string, FieldNameMeta>) { |
||||||
|
return (a: string, b: string) => { |
||||||
|
const la = labels[a]; |
||||||
|
const lb = labels[b]; |
||||||
|
|
||||||
|
// ...sort by type and alphabetically
|
||||||
|
if (la != null && lb != null) { |
||||||
|
return ( |
||||||
|
Number(lb.type === 'TIME_FIELD') - Number(la.type === 'TIME_FIELD') || |
||||||
|
Number(lb.type === 'BODY_FIELD') - Number(la.type === 'BODY_FIELD') || |
||||||
|
collator.compare(a, b) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// otherwise do not sort
|
||||||
|
return 0; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export const LogsTableAvailableFields = (props: { |
||||||
|
labels: Record<string, FieldNameMeta>; |
||||||
|
valueFilter: (value: string) => boolean; |
||||||
|
toggleColumn: (columnName: string) => void; |
||||||
|
}): JSX.Element => { |
||||||
|
const { labels, valueFilter, toggleColumn } = props; |
||||||
|
const theme = useTheme2(); |
||||||
|
const styles = getLogsFieldsStyles(theme); |
||||||
|
const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); |
||||||
|
if (labelKeys.length) { |
||||||
|
// Otherwise show list with a hardcoded order
|
||||||
|
return ( |
||||||
|
<div className={styles.columnWrapper}> |
||||||
|
{labelKeys.sort(sortLabels(labels)).map((labelName, index) => ( |
||||||
|
<div |
||||||
|
key={labelName} |
||||||
|
className={styles.wrap} |
||||||
|
title={`${labelName} appears in ${labels[labelName]?.percentOfLinesWithLabel}% of log lines`} |
||||||
|
> |
||||||
|
<LogsTableNavField |
||||||
|
showCount={true} |
||||||
|
label={labelName} |
||||||
|
onChange={() => toggleColumn(labelName)} |
||||||
|
labels={labels} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return <LogsTableEmptyFields />; |
||||||
|
}; |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { useTheme2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
empty: css({ |
||||||
|
marginBottom: theme.spacing(2), |
||||||
|
marginLeft: theme.spacing(1.75), |
||||||
|
fontSize: theme.typography.fontSize, |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function LogsTableEmptyFields() { |
||||||
|
const theme = useTheme2(); |
||||||
|
const styles = getStyles(theme); |
||||||
|
return <div className={styles.empty}>No fields</div>; |
||||||
|
} |
||||||
@ -1,108 +0,0 @@ |
|||||||
import { css } from '@emotion/css'; |
|
||||||
import React from 'react'; |
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data/src'; |
|
||||||
import { Checkbox, useTheme2 } from '@grafana/ui/src'; |
|
||||||
|
|
||||||
import { fieldNameMeta } from './LogsTableWrap'; |
|
||||||
|
|
||||||
function getStyles(theme: GrafanaTheme2) { |
|
||||||
return { |
|
||||||
labelCount: css({ |
|
||||||
marginLeft: theme.spacing(0.5), |
|
||||||
marginRight: theme.spacing(0.5), |
|
||||||
appearance: 'none', |
|
||||||
background: 'none', |
|
||||||
border: 'none', |
|
||||||
fontSize: theme.typography.pxToRem(11), |
|
||||||
}), |
|
||||||
wrap: css({ |
|
||||||
display: 'flex', |
|
||||||
alignItems: 'center', |
|
||||||
marginTop: theme.spacing(1), |
|
||||||
marginBottom: theme.spacing(1), |
|
||||||
justifyContent: 'space-between', |
|
||||||
}), |
|
||||||
// Making the checkbox sticky and label scrollable for labels that are wider then the container
|
|
||||||
// However, the checkbox component does not support this, so we need to do some css hackery for now until the API of that component is updated.
|
|
||||||
checkboxLabel: css({ |
|
||||||
'> :first-child': { |
|
||||||
position: 'sticky', |
|
||||||
left: 0, |
|
||||||
bottom: 0, |
|
||||||
top: 0, |
|
||||||
}, |
|
||||||
'> span': { |
|
||||||
overflow: 'hidden', |
|
||||||
textOverflow: 'ellipsis', |
|
||||||
whiteSpace: 'nowrap', |
|
||||||
display: 'block', |
|
||||||
maxWidth: '100%', |
|
||||||
}, |
|
||||||
}), |
|
||||||
columnWrapper: css({ |
|
||||||
marginBottom: theme.spacing(1.5), |
|
||||||
// need some space or the outline of the checkbox is cut off
|
|
||||||
paddingLeft: theme.spacing(0.5), |
|
||||||
}), |
|
||||||
empty: css({ |
|
||||||
marginBottom: theme.spacing(2), |
|
||||||
marginLeft: theme.spacing(1.75), |
|
||||||
fontSize: theme.typography.fontSize, |
|
||||||
}), |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); |
|
||||||
function sortLabels(labels: Record<string, fieldNameMeta>) { |
|
||||||
return (a: string, b: string) => { |
|
||||||
const la = labels[a]; |
|
||||||
const lb = labels[b]; |
|
||||||
|
|
||||||
if (la != null && lb != null) { |
|
||||||
return ( |
|
||||||
Number(lb.type === 'TIME_FIELD') - Number(la.type === 'TIME_FIELD') || |
|
||||||
Number(lb.type === 'BODY_FIELD') - Number(la.type === 'BODY_FIELD') || |
|
||||||
collator.compare(a, b) |
|
||||||
); |
|
||||||
} |
|
||||||
// otherwise do not sort
|
|
||||||
return 0; |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
export const LogsTableNavColumn = (props: { |
|
||||||
labels: Record<string, fieldNameMeta>; |
|
||||||
valueFilter: (value: string) => boolean; |
|
||||||
toggleColumn: (columnName: string) => void; |
|
||||||
}): JSX.Element => { |
|
||||||
const { labels, valueFilter, toggleColumn } = props; |
|
||||||
const theme = useTheme2(); |
|
||||||
const styles = getStyles(theme); |
|
||||||
const labelKeys = Object.keys(labels).filter((labelName) => valueFilter(labelName)); |
|
||||||
if (labelKeys.length) { |
|
||||||
return ( |
|
||||||
<div className={styles.columnWrapper}> |
|
||||||
{labelKeys.sort(sortLabels(labels)).map((labelName) => ( |
|
||||||
<div |
|
||||||
title={`${labelName} appears in ${labels[labelName]?.percentOfLinesWithLabel}% of log lines`} |
|
||||||
className={styles.wrap} |
|
||||||
key={labelName} |
|
||||||
> |
|
||||||
<Checkbox |
|
||||||
className={styles.checkboxLabel} |
|
||||||
label={labelName} |
|
||||||
onChange={() => toggleColumn(labelName)} |
|
||||||
checked={labels[labelName]?.active ?? false} |
|
||||||
/> |
|
||||||
<button className={styles.labelCount} onClick={() => toggleColumn(labelName)}> |
|
||||||
{labels[labelName]?.percentOfLinesWithLabel}% |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
))} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
return <div className={styles.empty}>No fields</div>; |
|
||||||
}; |
|
||||||
@ -0,0 +1,83 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { Checkbox, Icon, useTheme2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { FieldNameMeta } from './LogsTableWrap'; |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
dragIcon: css({ |
||||||
|
cursor: 'drag', |
||||||
|
marginLeft: theme.spacing(1), |
||||||
|
opacity: 0.4, |
||||||
|
}), |
||||||
|
labelCount: css({ |
||||||
|
marginLeft: theme.spacing(0.5), |
||||||
|
marginRight: theme.spacing(0.5), |
||||||
|
appearance: 'none', |
||||||
|
background: 'none', |
||||||
|
border: 'none', |
||||||
|
fontSize: theme.typography.pxToRem(11), |
||||||
|
opacity: 0.6, |
||||||
|
}), |
||||||
|
contentWrap: css({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
justifyContent: 'space-between', |
||||||
|
width: '100%', |
||||||
|
}), |
||||||
|
// Hide text that overflows, had to select elements within the Checkbox component, so this is a bit fragile
|
||||||
|
checkboxLabel: css({ |
||||||
|
'> span': { |
||||||
|
overflow: 'hidden', |
||||||
|
textOverflow: 'ellipsis', |
||||||
|
whiteSpace: 'nowrap', |
||||||
|
display: 'block', |
||||||
|
maxWidth: '100%', |
||||||
|
}, |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export function LogsTableNavField(props: { |
||||||
|
label: string; |
||||||
|
onChange: () => void; |
||||||
|
labels: Record<string, FieldNameMeta>; |
||||||
|
draggable?: boolean; |
||||||
|
showCount?: boolean; |
||||||
|
}): React.JSX.Element | undefined { |
||||||
|
const theme = useTheme2(); |
||||||
|
const styles = getStyles(theme); |
||||||
|
|
||||||
|
if (props.labels[props.label]) { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<div className={styles.contentWrap}> |
||||||
|
<Checkbox |
||||||
|
className={styles.checkboxLabel} |
||||||
|
label={props.label} |
||||||
|
onChange={props.onChange} |
||||||
|
checked={props.labels[props.label]?.active ?? false} |
||||||
|
/> |
||||||
|
{props.showCount && ( |
||||||
|
<button className={styles.labelCount} onClick={props.onChange}> |
||||||
|
{props.labels[props.label]?.percentOfLinesWithLabel}% |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{props.draggable && ( |
||||||
|
<Icon |
||||||
|
aria-label="Drag and drop icon" |
||||||
|
title="Drag and drop to reorder" |
||||||
|
name="draggabledots" |
||||||
|
size="lg" |
||||||
|
className={styles.dragIcon} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
return undefined; |
||||||
|
} |
||||||
Loading…
Reference in new issue