Table: Improvements to column resizing, style and alignment (#23663)

* Table: Fixed to column alignment

* testing table state reducer

* Styles starting to work

* Persisting column resize now works

* Trying to fix Table storybook stories

* Minor updates

* fixed ts issue

* Table: Support duplicate field names, and use data frame directly instead of copying data and other improvements (#23681)

* Poc at use data frame directly

* working ok

* Table improvements
This commit is contained in:
Torkel Ödegaard 2020-04-20 09:27:40 +02:00 committed by GitHub
parent 3aa8eb0176
commit 56a7de562e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 308 additions and 225 deletions

View File

@ -9,9 +9,8 @@ import {
GrafanaTheme,
TimeZone,
} from '../types';
import { Registry } from '../utils/Registry';
import { InterpolateFunction } from './panel';
import { StandardEditorProps } from '../field';
import { StandardEditorProps, FieldConfigOptionsRegistry } from '../field';
import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
export interface DynamicConfigValue {
@ -122,7 +121,7 @@ export interface ApplyFieldOverrideOptions {
theme: GrafanaTheme;
timeZone?: TimeZone;
autoMinMax?: boolean;
fieldConfigRegistry?: Registry<FieldConfigPropertyItem>;
fieldConfigRegistry?: FieldConfigOptionsRegistry;
}
export enum FieldConfigProperty {

View File

@ -13,7 +13,7 @@
.track-vertical {
border-radius: 3px;
width: 6px !important;
width: 8px !important;
right: 2px;
bottom: 2px;
top: 2px;
@ -21,7 +21,7 @@
.track-horizontal {
border-radius: 3px;
height: 6px !important;
height: 8px !important;
right: 2px;
bottom: 2px;

View File

@ -52,6 +52,7 @@ export const BarGaugeCell: FC<TableCellProps> = props => {
width={width}
height={tableStyles.cellHeightInner}
field={config}
display={field.display}
value={displayValue}
orientation={VizOrientation.Horizontal}
theme={tableStyles.theme}

View File

@ -1,4 +1,5 @@
import React from 'react';
import { merge } from 'lodash';
import { Table } from './Table';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { number } from '@storybook/addon-knobs';
@ -6,14 +7,13 @@ import { useTheme } from '../../themes';
import mdx from './Table.mdx';
import {
applyFieldOverrides,
ConfigOverrideRule,
DataFrame,
FieldMatcherID,
FieldType,
GrafanaTheme,
MutableDataFrame,
ThresholdsConfig,
ThresholdsMode,
FieldConfig,
} from '@grafana/data';
export default {
@ -27,7 +27,7 @@ export default {
},
};
function buildData(theme: GrafanaTheme, overrides: ConfigOverrideRule[]): DataFrame {
function buildData(theme: GrafanaTheme, config: Record<string, FieldConfig>): DataFrame {
const data = new MutableDataFrame({
fields: [
{ name: 'Time', type: FieldType.time, values: [] }, // The time field
@ -39,6 +39,7 @@ function buildData(theme: GrafanaTheme, overrides: ConfigOverrideRule[]): DataFr
decimals: 0,
custom: {
align: 'center',
width: 80,
},
},
},
@ -57,14 +58,20 @@ function buildData(theme: GrafanaTheme, overrides: ConfigOverrideRule[]): DataFr
values: [],
config: {
unit: 'percent',
min: 0,
max: 100,
custom: {
width: 100,
width: 150,
},
},
},
],
});
for (const field of data.fields) {
field.config = merge(field.config, config[field.name]);
}
for (let i = 0; i < 1000; i++) {
data.appendRow([
new Date().getTime(),
@ -78,7 +85,7 @@ function buildData(theme: GrafanaTheme, overrides: ConfigOverrideRule[]): DataFr
return applyFieldOverrides({
data: [data],
fieldConfig: {
overrides,
overrides: [],
defaults: {},
},
theme,
@ -86,40 +93,6 @@ function buildData(theme: GrafanaTheme, overrides: ConfigOverrideRule[]): DataFr
})[0];
}
export const Simple = () => {
const theme = useTheme();
const width = number('width', 700, {}, 'Props');
const data = buildData(theme, []);
return (
<div className="panel-container" style={{ width: 'auto' }}>
<Table data={data} height={500} width={width} />
</div>
);
};
export const BarGaugeCell = () => {
const theme = useTheme();
const width = number('width', 700, {}, 'Props');
const data = buildData(theme, [
{
matcher: { id: FieldMatcherID.byName, options: 'Progress' },
properties: [
{ id: 'width', value: '200' },
{ id: 'displayMode', value: 'gradient-gauge' },
{ id: 'min', value: '0' },
{ id: 'max', value: '100' },
],
},
]);
return (
<div className="panel-container" style={{ width: 'auto' }}>
<Table data={data} height={500} width={width} />
</div>
);
};
const defaultThresholds: ThresholdsConfig = {
steps: [
{
@ -134,21 +107,50 @@ const defaultThresholds: ThresholdsConfig = {
mode: ThresholdsMode.Absolute,
};
export const ColoredCells = () => {
export const Simple = () => {
const theme = useTheme();
const width = number('width', 750, {}, 'Props');
const data = buildData(theme, [
{
matcher: { id: FieldMatcherID.byName, options: 'Progress' },
properties: [
{ id: 'width', value: '80' },
{ id: 'displayMode', value: 'color-background' },
{ id: 'min', value: '0' },
{ id: 'max', value: '100' },
{ id: 'thresholds', value: defaultThresholds },
],
},
]);
const width = number('width', 700, {}, 'Props');
const data = buildData(theme, {});
return (
<div className="panel-container" style={{ width: 'auto' }}>
<Table data={data} height={500} width={width} />
</div>
);
};
export const BarGaugeCell = () => {
const theme = useTheme();
const width = number('width', 700, {}, 'Props');
const data = buildData(theme, {
Progress: {
custom: {
width: 200,
displayMode: 'gradient-gauge',
},
thresholds: defaultThresholds,
},
});
return (
<div className="panel-container" style={{ width: 'auto' }}>
<Table data={data} height={500} width={width} />
</div>
);
};
export const ColoredCells = () => {
const theme = useTheme();
const width = number('width', 750, {}, 'Props');
const data = buildData(theme, {
Progress: {
custom: {
width: 80,
displayMode: 'color-background',
},
thresholds: defaultThresholds,
},
});
return (
<div className="panel-container" style={{ width: 'auto' }}>

View File

@ -1,11 +1,20 @@
import React, { FC, memo, useMemo } from 'react';
import React, { FC, memo, useMemo, useCallback } from 'react';
import { DataFrame, Field } from '@grafana/data';
import { Cell, Column, HeaderGroup, useBlockLayout, useResizeColumns, useSortBy, useTable } from 'react-table';
import {
Cell,
Column,
HeaderGroup,
useAbsoluteLayout,
useResizeColumns,
useSortBy,
useTable,
UseResizeColumnsState,
UseSortByState,
} from 'react-table';
import { FixedSizeList } from 'react-window';
import useMeasure from 'react-use/lib/useMeasure';
import { getColumns, getTableRows, getTextAlign } from './utils';
import { getColumns, getTextAlign } from './utils';
import { useTheme } from '../../themes';
import { ColumnResizeActionCallback, TableFilterActionCallback } from './types';
import { TableColumnResizeActionCallback, TableFilterActionCallback, TableSortByActionCallback } from './types';
import { getTableStyles, TableStyles } from './styles';
import { TableCell } from './TableCell';
import { Icon } from '../Icon/Icon';
@ -22,109 +31,142 @@ export interface Props {
noHeader?: boolean;
resizable?: boolean;
onCellClick?: TableFilterActionCallback;
onColumnResize?: ColumnResizeActionCallback;
onColumnResize?: TableColumnResizeActionCallback;
onSortBy?: TableSortByActionCallback;
}
export const Table: FC<Props> = memo(
({ data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = false }) => {
const theme = useTheme();
const [ref, headerRowMeasurements] = useMeasure();
const tableStyles = getTableStyles(theme);
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth), [data, width, columnMinWidth]);
const memoizedData = useMemo(() => getTableRows(data), [data]);
interface ReactTableInternalState extends UseResizeColumnsState<{}>, UseSortByState<{}> {}
const defaultColumn = React.useMemo(
() => ({
minWidth: memoizedColumns.reduce((minWidth, column) => {
if (column.width) {
const width = typeof column.width === 'string' ? parseInt(column.width, 10) : column.width;
return Math.min(minWidth, width);
function useTableStateReducer(props: Props) {
return useCallback(
(newState: ReactTableInternalState, action: any) => {
console.log(action, newState);
switch (action.type) {
case 'columnDoneResizing':
if (props.onColumnResize) {
const info = (newState.columnResizing.headerIdWidths as any)[0];
const columnIdString = info[0];
const fieldIndex = parseInt(columnIdString, 10);
const width = Math.round(newState.columnResizing.columnWidths[columnIdString] as number);
props.onColumnResize(fieldIndex, width);
}
return minWidth;
}, columnMinWidth),
}),
[columnMinWidth, memoizedColumns]
);
case 'toggleSortBy':
if (props.onSortBy) {
// todo call callback and persist
}
break;
}
const options: any = useMemo(
() => ({
columns: memoizedColumns,
data: memoizedData,
disableResizing: !resizable,
defaultColumn,
}),
[memoizedColumns, memoizedData, resizable, defaultColumn]
);
return newState;
},
[props.onColumnResize]
);
}
const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
options,
useBlockLayout,
useResizeColumns,
useSortBy
);
export const Table: FC<Props> = memo((props: Props) => {
const { data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = true } = props;
const theme = useTheme();
const tableStyles = getTableStyles(theme);
const RenderRow = React.useCallback(
({ index, style }) => {
const row = rows[index];
prepareRow(row);
return (
<div {...row.getRowProps({ style })} className={tableStyles.row}>
{row.cells.map((cell: Cell, index: number) => (
<TableCell
key={index}
field={data.fields[index]}
tableStyles={tableStyles}
cell={cell}
onCellClick={onCellClick}
/>
))}
</div>
);
},
[prepareRow, rows]
);
// React table data array. This data acts just like a dummy array to let react-table know how many rows exist
// The cells use the field to look up values
const memoizedData = useMemo(() => {
return data.fields.length > 0 ? data.fields[0].values.toArray() : [];
}, [data]);
return (
<div {...getTableProps()} className={tableStyles.table}>
<CustomScrollbar hideVerticalTrack={true}>
<div style={{ width: `${totalColumnsWidth}px` }}>
{!noHeader && (
<div>
{headerGroups.map((headerGroup: HeaderGroup) => {
return (
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
{headerGroup.headers.map((column: Column, index: number) =>
renderHeaderCell(column, tableStyles, data.fields[index])
)}
</div>
);
})}
</div>
)}
<FixedSizeList
height={height - headerRowMeasurements.height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</div>
</CustomScrollbar>
</div>
);
}
);
// React-table column definitions
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth), [data, width, columnMinWidth]);
// Internal react table state reducer
const stateReducer = useTableStateReducer(props);
const options: any = useMemo(
() => ({
columns: memoizedColumns,
data: memoizedData,
disableResizing: !resizable,
stateReducer: stateReducer,
// this is how you set initial sort by state
// initialState: {
// sortBy: [{ id: '2', desc: true }],
// },
}),
[memoizedColumns, memoizedData, stateReducer, resizable]
);
const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
options,
useSortBy,
useAbsoluteLayout,
useResizeColumns
);
const RenderRow = React.useCallback(
({ index, style }) => {
const row = rows[index];
prepareRow(row);
return (
<div {...row.getRowProps({ style })} className={tableStyles.row}>
{row.cells.map((cell: Cell, index: number) => (
<TableCell
key={index}
field={data.fields[index]}
tableStyles={tableStyles}
cell={cell}
onCellClick={onCellClick}
/>
))}
</div>
);
},
[prepareRow, rows]
);
const headerHeight = noHeader ? 0 : tableStyles.cellHeight;
return (
<div {...getTableProps()} className={tableStyles.table}>
<CustomScrollbar hideVerticalTrack={true}>
<div style={{ width: `${totalColumnsWidth}px` }}>
{!noHeader && (
<div>
{headerGroups.map((headerGroup: HeaderGroup) => {
return (
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()}>
{headerGroup.headers.map((column: Column, index: number) =>
renderHeaderCell(column, tableStyles, data.fields[index])
)}
</div>
);
})}
</div>
)}
<FixedSizeList
height={height - headerHeight}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</div>
</CustomScrollbar>
</div>
);
});
Table.displayName = 'Table';
function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field) {
const headerProps = column.getHeaderProps();
if (column.canResize) {
headerProps.style.userSelect = column.isResizing ? 'none' : 'auto'; // disables selecting text while resizing
}
headerProps.style.position = 'absolute';
headerProps.style.textAlign = getTextAlign(field);
return (

View File

@ -1,6 +1,6 @@
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { stylesFactory } from '../../themes';
import { stylesFactory, styleMixins } from '../../themes';
export interface TableStyles {
cellHeight: number;
@ -20,13 +20,14 @@ export interface TableStyles {
export const getTableStyles = stylesFactory(
(theme: GrafanaTheme): TableStyles => {
const { palette, colors } = theme;
const headerBg = theme.colors.panelBorder;
const headerBorderColor = theme.isLight ? palette.gray70 : palette.gray05;
const resizerColor = theme.isLight ? palette.blue77 : palette.blue95;
const headerBg = theme.colors.bg2;
const borderColor = theme.colors.border1;
const resizerColor = theme.isLight ? palette.blue95 : palette.blue77;
const padding = 6;
const lineHeight = theme.typography.lineHeight.md;
const bodyFontSize = 14;
const cellHeight = padding * 2 + bodyFontSize * lineHeight;
const rowHoverBg = styleMixins.hoverColor(theme.colors.bg1, theme);
return {
theme,
@ -42,6 +43,7 @@ export const getTableStyles = stylesFactory(
`,
thead: css`
label: thead;
height: ${cellHeight}px;
overflow-y: auto;
overflow-x: hidden;
background: ${headerBg};
@ -52,7 +54,7 @@ export const getTableStyles = stylesFactory(
cursor: pointer;
white-space: nowrap;
color: ${colors.textBlue};
border-right: 1px solid ${headerBorderColor};
border-right: 1px solid ${theme.colors.panelBg};
&:last-child {
border-right: none;
@ -60,10 +62,14 @@ export const getTableStyles = stylesFactory(
`,
row: css`
label: row;
border-bottom: 1px solid ${headerBg};
border-bottom: 1px solid ${borderColor};
&:hover {
background-color: ${rowHoverBg};
}
`,
tableCellWrapper: css`
border-right: 1px solid ${headerBg};
border-right: 1px solid ${borderColor};
&:last-child {
border-right: none;
@ -79,13 +85,14 @@ export const getTableStyles = stylesFactory(
label: resizeHandle;
cursor: col-resize !important;
display: inline-block;
border-right: 2px solid ${resizerColor};
background: ${resizerColor};
opacity: 0;
transition: opacity 0.2s ease-in-out;
width: 10px;
width: 8px;
height: 100%;
position: absolute;
right: 0;
right: -4px;
border-radius: 3px;
top: 0;
z-index: ${theme.zIndex.dropdown};
touch-action: none;

View File

@ -24,7 +24,13 @@ export interface TableRow {
}
export type TableFilterActionCallback = (key: string, value: string) => void;
export type ColumnResizeActionCallback = (field: Field, width: number) => void;
export type TableColumnResizeActionCallback = (fieldIndex: number, width: number) => void;
export type TableSortByActionCallback = (state: TableSortByFieldState[]) => void;
export interface TableSortByFieldState {
fieldIndex: number;
desc?: boolean;
}
export interface TableCellProps extends CellProps<any> {
tableStyles: TableStyles;

View File

@ -3,26 +3,11 @@ import { DataFrame, Field, FieldType } from '@grafana/data';
import { Column } from 'react-table';
import { DefaultCell } from './DefaultCell';
import { BarGaugeCell } from './BarGaugeCell';
import { TableCellDisplayMode, TableCellProps, TableFieldOptions, TableRow } from './types';
import { TableCellDisplayMode, TableCellProps, TableFieldOptions } from './types';
import { css, cx } from 'emotion';
import { withTableStyles } from './withTableStyles';
import tinycolor from 'tinycolor2';
export function getTableRows(data: DataFrame): TableRow[] {
const tableData = [];
for (let i = 0; i < data.length; i++) {
const row: { [key: string]: string | number } = {};
for (let j = 0; j < data.fields.length; j++) {
const prop = data.fields[j].name;
row[prop] = data.fields[j].values.get(i);
}
tableData.push(row);
}
return tableData;
}
export function getTextAlign(field?: Field): TextAlignProperty {
if (!field) {
return 'left';
@ -52,8 +37,10 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid
const columns: Column[] = [];
let fieldCountWithoutWidth = data.fields.length;
for (const field of data.fields) {
for (let fieldIndex = 0; fieldIndex < data.fields.length; fieldIndex++) {
const field = data.fields[fieldIndex];
const fieldTableOptions = (field.config.custom || {}) as TableFieldOptions;
if (fieldTableOptions.width) {
availableWidth -= fieldTableOptions.width;
fieldCountWithoutWidth -= 1;
@ -63,10 +50,13 @@ export function getColumns(data: DataFrame, availableWidth: number, columnMinWid
columns.push({
Cell,
id: field.name,
id: fieldIndex.toString(),
Header: field.config.title ?? field.name,
accessor: field.name,
accessor: (row: any, i: number) => {
return field.values.get(i);
},
width: fieldTableOptions.width,
minWidth: 50,
});
}

View File

@ -12,8 +12,14 @@ export function cardChrome(theme: GrafanaTheme): string {
`;
}
export function hoverColor(color: string, theme: GrafanaTheme) {
return theme.isDark ? tinycolor(color).brighten(2) : tinycolor(color).darken(2);
export function hoverColor(color: string, theme: GrafanaTheme): string {
return theme.isDark
? tinycolor(color)
.brighten(2)
.toString()
: tinycolor(color)
.darken(2)
.toString();
}
export function listItem(theme: GrafanaTheme): string {

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { Table, Select } from '@grafana/ui';
import { Field, FieldMatcherID, PanelProps, DataFrame, SelectableValue } from '@grafana/data';
import { FieldMatcherID, PanelProps, DataFrame, SelectableValue } from '@grafana/data';
import { Options } from './types';
import { css } from 'emotion';
import { config } from 'app/core/config';
@ -13,21 +13,44 @@ export class TablePanel extends Component<Props> {
super(props);
}
onColumnResize = (field: Field, width: number) => {
const current = this.props.fieldConfig;
const matcherId = FieldMatcherID.byName;
const prop = 'width';
const overrides = current.overrides.filter(
o => o.matcher.id !== matcherId || o.matcher.options !== field.name || o.properties[0].id !== prop
);
onColumnResize = (fieldIndex: number, width: number) => {
const { fieldConfig, data } = this.props;
const { overrides } = fieldConfig;
const frame = data.series[this.getCurrentFrameIndex()];
overrides.push({
matcher: { id: matcherId, options: field.name },
properties: [{ id: prop, value: width }],
});
if (!frame) {
return;
}
const field = frame.fields[fieldIndex];
if (!field) {
return;
}
const fieldName = field.name;
const matcherId = FieldMatcherID.byName;
const propId = 'custom.width';
// look for existing override
const override = overrides.find(o => o.matcher.id === matcherId && o.matcher.options === fieldName);
if (override) {
// look for existing property
const property = override.properties.find(prop => prop.id === propId);
if (property) {
property.value = width;
} else {
override.properties.push({ id: propId, value: width });
}
} else {
overrides.push({
matcher: { id: matcherId, options: fieldName },
properties: [{ id: propId, value: width }],
});
}
this.props.onFieldConfigChange({
...current,
...fieldConfig,
overrides,
});
};
@ -43,19 +66,28 @@ export class TablePanel extends Component<Props> {
};
renderTable(frame: DataFrame, width: number, height: number) {
const {
options: { showHeader, resizable },
} = this.props;
return <Table height={height} width={width} data={frame} noHeader={!showHeader} resizable={resizable} />;
const { options } = this.props;
return (
<Table
height={height}
width={width}
data={frame}
noHeader={!options.showHeader}
resizable={true}
onColumnResize={this.onColumnResize}
/>
);
}
getCurrentFrameIndex() {
const { data, options } = this.props;
const count = data.series?.length;
return options.frameIndex > 0 && options.frameIndex < count ? options.frameIndex : 0;
}
render() {
const {
data,
height,
width,
options: { frameIndex },
} = this.props;
const { data, height, width } = this.props;
const count = data.series?.length;
@ -65,8 +97,8 @@ export class TablePanel extends Component<Props> {
if (count > 1) {
const inputHeight = config.theme.spacing.formInputHeight;
const padding = 8;
const index = frameIndex > 0 && frameIndex < count ? frameIndex : 0;
const padding = 8 * 2;
const currentIndex = this.getCurrentFrameIndex();
const names = data.series.map((frame, index) => {
return {
label: `${frame.name ?? 'Series'}`,
@ -76,13 +108,15 @@ export class TablePanel extends Component<Props> {
return (
<div className={tableStyles.wrapper}>
{this.renderTable(data.series[index], width, height - inputHeight - padding)}
<Select options={names} value={names[index]} onChange={this.onChangeTableSelection} />
{this.renderTable(data.series[currentIndex], width, height - inputHeight - padding)}
<div className={tableStyles.selectWrapper}>
<Select options={names} value={names[currentIndex]} onChange={this.onChangeTableSelection} />
</div>
</div>
);
}
return this.renderTable(data.series[0], width, height);
return this.renderTable(data.series[0], width, height - 12);
}
}
@ -93,4 +127,7 @@ const tableStyles = {
justify-content: space-between;
height: 100%;
`,
selectWrapper: css`
padding: 8px;
`,
};

View File

@ -6,23 +6,23 @@ import { tablePanelChangedHandler, tableMigrationHandler } from './migrations';
export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel)
.setPanelChangeHandler(tablePanelChangedHandler)
.setMigrationHandler(tableMigrationHandler)
.setNoPadding()
.useFieldConfig({
useCustomConfig: builder => {
builder
.addNumberInput({
path: 'width',
name: 'Column width',
description: 'column width (for table)',
settings: {
placeholder: 'auto',
min: 20,
max: 300,
},
shouldApply: () => true,
})
.addRadio({
path: 'align',
name: 'Column alignment',
description: 'column alignment (for table)',
settings: {
options: [
{ label: 'auto', value: null },
@ -50,17 +50,10 @@ export const plugin = new PanelPlugin<Options, CustomFieldConfig>(TablePanel)
},
})
.setPanelOptions(builder => {
builder
.addBooleanSwitch({
path: 'showHeader',
name: 'Show header',
description: "To display table's header or not to display",
defaultValue: true,
})
.addBooleanSwitch({
path: 'resizable',
name: 'Resizable',
description: 'Toggles if table columns are resizable or not',
defaultValue: false,
});
builder.addBooleanSwitch({
path: 'showHeader',
name: 'Show header',
description: "To display table's header or not to display",
defaultValue: true,
});
});

View File

@ -1,7 +1,6 @@
export interface Options {
frameIndex: number;
showHeader: boolean;
resizable: boolean;
}
export interface CustomFieldConfig {

View File

@ -33,6 +33,7 @@ $panel-header-no-title-zindex: 1;
flex-wrap: nowrap;
justify-content: center;
height: $panel-header-height;
line-height: $panel-header-height;
align-items: center;
}