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