mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Table: Add row number column option (#62256)
* baldm0mma/addRowNumbers/ add showRowNums to panel cue * baldm0mma/addRowNumbers/ add panel option for sowing row numbers * baldm0mma/addRowNumbers/ update typing for showRowNums * baldm0mma/addRowNumbers/ add buildFieldsForOptionalRowNums * baldm0mma/addRowNumbers/ update addOptionalNumbersRowToTable * baldm0mma/addRowNumbers/ adjust display method to return numeric and text values * baldm0mma/ chaneg prop name to match * baldm0mma/addRowNumbers/ update boolean swicth path * baldm0mma/addRowNumbers/ move function * baldm0mma/addRowNumbers/ add getToggleHiddenProps * baldm0mma/addRowNumbers/ remove addNumbersRowToTable second arg * baldm0mma/addRowNumbers/ add updateInitialState * baldm0mma/addRowNumbers/ update getInitialState reducer with initialShowRowNumbers arg * baldm0mma/addRowNumbers/ add useEffect for RowNumberColumn toggling * baldm0mma/addRowNums/ bootleg fix * baldm0mma/addRowNumbers/ export OPTIONAL_ROW_NUMBER_COLUMN_WIDTH * baldm0mma/addRowNumbers/ add annos for readability * baldm0mma/addRowNumbers/ remove superfluous annos * baldm0mma/addRowNumbers/ add a few logs * baldm0mma/addRowNumbers/ update annos * baldm0mma/addRowNumbers/ update which footer row displays reducer type * baldm0mma/addRowNumbers/ abstract away defaultRowNumberColumnFieldData * baldm0mma/addRowNumbers/ update annos in utils.tsx * baldm0mma/addRowNumbers/ update annos for defaultRowNumberColumnFieldData * baldm0mma/addRowNumbers/ mark unused args with underscore * baldm0mma/addRowNumbers/ add annos to addRowNumbersFieldToData * baldm0mma/addRowNumbers/ update utils file type * baldm0mma/addRowNumbers/ remove console.logs * baldm0mma/addRowNumbers/ update file type * baldm0mma/addRowNumbers/ update annos in utils * baldm0mma/addRowNumbers/ remove superfluous footerGroups object * baldm0mma/addRowNumbers/ add annos for tests * baldm0mma/addRowNumbers/ add annos for self * baldm0mma/addRowNumbers/ add tests to table.test.tsx * baldm0mma/addRowNumbers/ update tests in utils.test * baldm0mma/addRowNumbers/ update annos and tests * baldm0mma/addRowNumbers/ remove console.logs * baldm0mma/addRowNumbers/ update utils file ext * baldm0mma/addRowNumbers/ update anno in table.tsx * baldm0mma/addRowNumbers/ update annos in table.tsx * baldm0mma/addRowNumbers/ rem error annos * baldm0mma/addRowNumbers/ revert footerCell * baldm0mma/addRowNumbers/ revert tests * baldm0mma/addRowNumbers/ skip tests * baldm0mma/addRowNumbers/ revert table isCountRowSet * baldm0mma/addRowNumbers/ remove cloneDeep * baldm0mma/addRowNumbers/ update filterFields * baldm0mma/addRowNumbers/ skip tests * Refactor count rows * baldm0mma/addRowNumbers/ rem test skips * baldm0mma/addRowNumbers/ update with annos * baldm0mma/addRowNumbers/ skip timeing out test * baldm0mma/addRowNumbers/ static row numbering and test updates * baldm0mma/addRowNumbers/ remove dupe --------- Co-authored-by: Victor Marin <victor.marin@grafana.com>
This commit is contained in:
parent
41dc88bd25
commit
2e98f5063b
@ -1487,7 +1487,7 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "9"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "9"]
|
||||||
],
|
],
|
||||||
"packages/grafana-ui/src/components/Table/utils.tsx:5381": [
|
"packages/grafana-ui/src/components/Table/utils.ts:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
|
||||||
|
@ -25,6 +25,7 @@ export const FooterCell = (props: FooterProps) => {
|
|||||||
if (props.value && !Array.isArray(props.value)) {
|
if (props.value && !Array.isArray(props.value)) {
|
||||||
return <span>{props.value}</span>;
|
return <span>{props.value}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.value && Array.isArray(props.value) && props.value.length > 0) {
|
if (props.value && Array.isArray(props.value) && props.value.length > 0) {
|
||||||
return (
|
return (
|
||||||
<ul className={cell}>
|
<ul className={cell}>
|
||||||
@ -32,7 +33,7 @@ export const FooterCell = (props: FooterProps) => {
|
|||||||
const key = Object.keys(v)[0];
|
const key = Object.keys(v)[0];
|
||||||
return (
|
return (
|
||||||
<li className={list} key={i}>
|
<li className={list} key={i}>
|
||||||
<span>{key}:</span>
|
<span>{key}</span>
|
||||||
<span>{v[key]}</span>
|
<span>{v[key]}</span>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -40,6 +41,7 @@ export const FooterCell = (props: FooterProps) => {
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return EmptyCell;
|
return EmptyCell;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ColumnInstance, HeaderGroup } from 'react-table';
|
import { ColumnInstance, HeaderGroup } from 'react-table';
|
||||||
|
|
||||||
|
import { fieldReducers, ReducerID } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
import { EmptyCell, FooterCell } from './FooterCell';
|
import { EmptyCell, FooterCell } from './FooterCell';
|
||||||
@ -63,12 +64,13 @@ export function getFooterValue(index: number, footerValues?: FooterItem[], isCou
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isCountRowsSet) {
|
if (isCountRowsSet) {
|
||||||
const count = footerValues[index];
|
if (footerValues[index] === undefined) {
|
||||||
if (typeof count !== 'string') {
|
|
||||||
return EmptyCell;
|
return EmptyCell;
|
||||||
}
|
}
|
||||||
|
|
||||||
return FooterCell({ value: [{ Count: count }] });
|
const key = fieldReducers.get(ReducerID.count).name;
|
||||||
|
|
||||||
|
return FooterCell({ value: [{ [key]: String(footerValues[index]) }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return FooterCell({ value: footerValues[index] });
|
return FooterCell({ value: footerValues[index] });
|
||||||
|
@ -152,6 +152,38 @@ describe('Table', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when `showRowNums` is toggled', () => {
|
||||||
|
const showRowNumsTestContext = {
|
||||||
|
data: toDataFrame({
|
||||||
|
name: 'A',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'number',
|
||||||
|
type: FieldType.number,
|
||||||
|
values: [1, 1, 1, 2, 2, 3, 4, 5],
|
||||||
|
config: {
|
||||||
|
custom: {
|
||||||
|
filterable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should render the (fields.length) rows when `showRowNums` is untoggled', () => {
|
||||||
|
getTestContext({ ...showRowNumsTestContext, showRowNums: false });
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('columnheader')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render (fields.length + 1) rows row when `showRowNums` is toggled', () => {
|
||||||
|
getTestContext({ ...showRowNumsTestContext, showRowNums: true });
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('columnheader')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('when mounted with footer', () => {
|
describe('when mounted with footer', () => {
|
||||||
it('then footer should be displayed', () => {
|
it('then footer should be displayed', () => {
|
||||||
const footerValues = ['a', 'b', 'c'];
|
const footerValues = ['a', 'b', 'c'];
|
||||||
@ -445,7 +477,7 @@ describe('Table', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual(
|
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual(
|
||||||
'Count:'
|
'Count'
|
||||||
);
|
);
|
||||||
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5');
|
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5');
|
||||||
});
|
});
|
||||||
@ -471,7 +503,7 @@ describe('Table', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual(
|
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[0].textContent).toEqual(
|
||||||
'Count:'
|
'Count'
|
||||||
);
|
);
|
||||||
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5');
|
expect(within(getFooter()).getByRole('columnheader').getElementsByTagName('span')[1].textContent).toEqual('5');
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { clone } from 'lodash';
|
||||||
|
import React, { CSSProperties, memo, useCallback, useEffect, useMemo, useRef, useState, UIEventHandler } from 'react';
|
||||||
import {
|
import {
|
||||||
Cell,
|
Cell,
|
||||||
useAbsoluteLayout,
|
useAbsoluteLayout,
|
||||||
@ -11,7 +12,7 @@ import {
|
|||||||
} from 'react-table';
|
} from 'react-table';
|
||||||
import { VariableSizeList } from 'react-window';
|
import { VariableSizeList } from 'react-window';
|
||||||
|
|
||||||
import { Field, ReducerID } from '@grafana/data';
|
import { DataFrame, Field, ReducerID } from '@grafana/data';
|
||||||
|
|
||||||
import { useStyles2, useTheme2 } from '../../themes';
|
import { useStyles2, useTheme2 } from '../../themes';
|
||||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||||
@ -31,6 +32,7 @@ import {
|
|||||||
getFooterItems,
|
getFooterItems,
|
||||||
createFooterCalculationValues,
|
createFooterCalculationValues,
|
||||||
EXPANDER_WIDTH,
|
EXPANDER_WIDTH,
|
||||||
|
buildFieldsForOptionalRowNums,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
const COLUMN_MIN_WIDTH = 150;
|
const COLUMN_MIN_WIDTH = 150;
|
||||||
@ -48,6 +50,7 @@ export const Table = memo((props: Props) => {
|
|||||||
columnMinWidth = COLUMN_MIN_WIDTH,
|
columnMinWidth = COLUMN_MIN_WIDTH,
|
||||||
noHeader,
|
noHeader,
|
||||||
resizable = true,
|
resizable = true,
|
||||||
|
showRowNums,
|
||||||
initialSortBy,
|
initialSortBy,
|
||||||
footerOptions,
|
footerOptions,
|
||||||
showTypeIcons,
|
showTypeIcons,
|
||||||
@ -84,8 +87,8 @@ export const Table = memo((props: Props) => {
|
|||||||
return EXTENDED_ROW_HEIGHT;
|
return EXTENDED_ROW_HEIGHT;
|
||||||
}, [footerItems]);
|
}, [footerItems]);
|
||||||
|
|
||||||
// React table data array. This data acts just like a dummy array to let react-table know how many rows exist
|
// 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
|
// The cells use the field to look up values, therefore this is simply a length/size placeholder.
|
||||||
const memoizedData = useMemo(() => {
|
const memoizedData = useMemo(() => {
|
||||||
if (!data.fields.length) {
|
if (!data.fields.length) {
|
||||||
return [];
|
return [];
|
||||||
@ -96,6 +99,7 @@ export const Table = memo((props: Props) => {
|
|||||||
return Array(data.length).fill(0);
|
return Array(data.length).fill(0);
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
// This checks whether `Show table footer` is toggled on, the `Calculation` is set to `Count`, and finally, whether `Count rows` is toggled on.
|
||||||
const isCountRowsSet = Boolean(
|
const isCountRowsSet = Boolean(
|
||||||
footerOptions?.countRows &&
|
footerOptions?.countRows &&
|
||||||
footerOptions.reducer &&
|
footerOptions.reducer &&
|
||||||
@ -105,7 +109,8 @@ export const Table = memo((props: Props) => {
|
|||||||
|
|
||||||
// React-table column definitions
|
// React-table column definitions
|
||||||
const memoizedColumns = useMemo(
|
const memoizedColumns = useMemo(
|
||||||
() => getColumns(data, width, columnMinWidth, !!subData?.length, footerItems, isCountRowsSet),
|
() =>
|
||||||
|
getColumns(addRowNumbersFieldToData(data), width, columnMinWidth, !!subData?.length, footerItems, isCountRowsSet),
|
||||||
[data, width, columnMinWidth, footerItems, subData, isCountRowsSet]
|
[data, width, columnMinWidth, footerItems, subData, isCountRowsSet]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -118,14 +123,14 @@ export const Table = memo((props: Props) => {
|
|||||||
data: memoizedData,
|
data: memoizedData,
|
||||||
disableResizing: !resizable,
|
disableResizing: !resizable,
|
||||||
stateReducer: stateReducer,
|
stateReducer: stateReducer,
|
||||||
initialState: getInitialState(initialSortBy, memoizedColumns),
|
initialState: getInitialState(initialSortBy, showRowNums, memoizedColumns),
|
||||||
autoResetFilters: false,
|
autoResetFilters: false,
|
||||||
sortTypes: {
|
sortTypes: {
|
||||||
number: sortNumber, // the builtin number type on react-table does not handle NaN values
|
number: sortNumber, // the builtin number type on react-table does not handle NaN values
|
||||||
'alphanumeric-insensitive': sortCaseInsensitive, // should be replace with the builtin string when react-table is upgraded, see https://github.com/tannerlinsley/react-table/pull/3235
|
'alphanumeric-insensitive': sortCaseInsensitive, // should be replace with the builtin string when react-table is upgraded, see https://github.com/tannerlinsley/react-table/pull/3235
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[initialSortBy, memoizedColumns, memoizedData, resizable, stateReducer]
|
[initialSortBy, showRowNums, memoizedColumns, memoizedData, resizable, stateReducer]
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -134,16 +139,21 @@ export const Table = memo((props: Props) => {
|
|||||||
rows,
|
rows,
|
||||||
prepareRow,
|
prepareRow,
|
||||||
totalColumnsWidth,
|
totalColumnsWidth,
|
||||||
footerGroups,
|
|
||||||
page,
|
page,
|
||||||
state,
|
state,
|
||||||
gotoPage,
|
gotoPage,
|
||||||
setPageSize,
|
setPageSize,
|
||||||
pageOptions,
|
pageOptions,
|
||||||
|
setHiddenColumns,
|
||||||
} = useTable(options, useFilters, useSortBy, useAbsoluteLayout, useResizeColumns, useExpanded, usePagination);
|
} = useTable(options, useFilters, useSortBy, useAbsoluteLayout, useResizeColumns, useExpanded, usePagination);
|
||||||
|
|
||||||
const extendedState = state as GrafanaTableState;
|
const extendedState = state as GrafanaTableState;
|
||||||
|
|
||||||
|
// Hide Row Number column on toggle
|
||||||
|
useEffect(() => {
|
||||||
|
!!showRowNums ? setHiddenColumns([]) : setHiddenColumns(['0']);
|
||||||
|
}, [showRowNums, setHiddenColumns]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Footer value calculation is being moved in the Table component and the footerValues prop will be deprecated.
|
Footer value calculation is being moved in the Table component and the footerValues prop will be deprecated.
|
||||||
The footerValues prop is still used in the Table component for backwards compatibility. Adding the
|
The footerValues prop is still used in the Table component for backwards compatibility. Adding the
|
||||||
@ -166,20 +176,31 @@ export const Table = memo((props: Props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCountRowsSet) {
|
||||||
|
const footerItemsCountRows: FooterItem[] = [];
|
||||||
|
/*
|
||||||
|
Update the 1st index of the empty array with the row length, which will then default the 0th index to `undefined`,
|
||||||
|
which will therefore account for our Row Numbers column, and render the count in the following column.
|
||||||
|
This will work with tables with only a single column as well, since our Row Numbers column is being prepended reguardless.
|
||||||
|
*/
|
||||||
|
footerItemsCountRows[1] = headerGroups[0]?.headers[0]?.filteredRows.length.toString() ?? data.length.toString();
|
||||||
|
setFooterItems(footerItemsCountRows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const footerItems = getFooterItems(
|
const footerItems = getFooterItems(
|
||||||
headerGroups[0].headers as unknown as Array<{ field: Field }>,
|
/*
|
||||||
|
The `headerGroups` object is NOT based on the `data.fields`, but instead on the currently rendered headers in the Table,
|
||||||
|
which may or may not include the Row Numbers column.
|
||||||
|
*/
|
||||||
|
headerGroups[0].headers as unknown as Array<{ id: string; field: Field }>,
|
||||||
|
// The `rows` object, on the other hand, is based on the `data.fields` data, and therefore ALWAYS include the Row Numbers column data.
|
||||||
createFooterCalculationValues(rows),
|
createFooterCalculationValues(rows),
|
||||||
footerOptions,
|
footerOptions,
|
||||||
theme
|
theme
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isCountRowsSet) {
|
setFooterItems(footerItems);
|
||||||
const footerItemsCountRows: FooterItem[] = new Array(footerItems.length).fill(undefined);
|
|
||||||
footerItemsCountRows[0] = data.length.toString();
|
|
||||||
setFooterItems(footerItemsCountRows);
|
|
||||||
} else {
|
|
||||||
setFooterItems(footerItems);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [footerOptions, theme, state.filters, data]);
|
}, [footerOptions, theme, state.filters, data]);
|
||||||
|
|
||||||
@ -214,7 +235,7 @@ export const Table = memo((props: Props) => {
|
|||||||
useResetVariableListSizeCache(extendedState, listRef, data);
|
useResetVariableListSizeCache(extendedState, listRef, data);
|
||||||
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
|
useFixScrollbarContainer(variableSizeListScrollbarRef, tableDivRef);
|
||||||
|
|
||||||
const renderSubTable = React.useCallback(
|
const renderSubTable = useCallback(
|
||||||
(rowIndex: number) => {
|
(rowIndex: number) => {
|
||||||
if (state.expanded[rowIndex]) {
|
if (state.expanded[rowIndex]) {
|
||||||
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === rowIndex);
|
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === rowIndex);
|
||||||
@ -245,28 +266,40 @@ export const Table = memo((props: Props) => {
|
|||||||
[state.expanded, subData, tableStyles.rowHeight, theme.colors, width]
|
[state.expanded, subData, tableStyles.rowHeight, theme.colors, width]
|
||||||
);
|
);
|
||||||
|
|
||||||
const RenderRow = React.useCallback(
|
const RenderRow = useCallback(
|
||||||
({ index: rowIndex, style }: { index: number; style: CSSProperties }) => {
|
({ index: rowIndex, style }: { index: number; style: CSSProperties }) => {
|
||||||
let row = rows[rowIndex];
|
let row = rows[rowIndex];
|
||||||
if (enablePagination) {
|
if (enablePagination) {
|
||||||
row = page[rowIndex];
|
row = page[rowIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareRow(row);
|
prepareRow(row);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...row.getRowProps({ style })} className={tableStyles.row}>
|
<div {...row.getRowProps({ style })} className={tableStyles.row}>
|
||||||
{/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
|
{/*add the subtable to the DOM first to prevent a 1px border CSS issue on the last cell of the row*/}
|
||||||
{renderSubTable(rowIndex)}
|
{renderSubTable(rowIndex)}
|
||||||
{row.cells.map((cell: Cell, index: number) => (
|
{row.cells.map((cell: Cell, index: number) => {
|
||||||
<TableCell
|
/*
|
||||||
key={index}
|
Here we test if the `row.cell` is of id === "0"; only if the user has toggled ON `Show row numbers` in the panelOptions panel will this cell exist.
|
||||||
tableStyles={tableStyles}
|
This cell had already been built, but with undefined values. This is so we can now update our empty/undefined `cell.value` to the current `rowIndex + 1`.
|
||||||
cell={cell}
|
This will assure that on sort, our row numbers don't also sort; but instewad stay in their respective rows.
|
||||||
onCellFilterAdded={onCellFilterAdded}
|
*/
|
||||||
columnIndex={index}
|
if (cell.column.id === '0') {
|
||||||
columnCount={row.cells.length}
|
cell.value = rowIndex + 1;
|
||||||
/>
|
}
|
||||||
))}
|
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={index}
|
||||||
|
tableStyles={tableStyles}
|
||||||
|
cell={cell}
|
||||||
|
onCellFilterAdded={onCellFilterAdded}
|
||||||
|
columnIndex={index}
|
||||||
|
columnCount={row.cells.length}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -309,6 +342,19 @@ export const Table = memo((props: Props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This adds the `Field` data needed to display a column with Row Numbers.
|
||||||
|
function addRowNumbersFieldToData(data: DataFrame): DataFrame {
|
||||||
|
/*
|
||||||
|
The `length` prop in a DataFrame tells us the amount of rows of data that will appear in our table;
|
||||||
|
with that we can build the correct buffered incrementing values for our Row Number column data.
|
||||||
|
*/
|
||||||
|
const rowField: Field = buildFieldsForOptionalRowNums(data.length);
|
||||||
|
// Clone data to avoid unwanted mutation.
|
||||||
|
const clonedData = clone(data);
|
||||||
|
clonedData.fields = [rowField, ...data.fields];
|
||||||
|
return clonedData;
|
||||||
|
}
|
||||||
|
|
||||||
const getItemSize = (index: number): number => {
|
const getItemSize = (index: number): number => {
|
||||||
if (state.expanded[index]) {
|
if (state.expanded[index]) {
|
||||||
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === index);
|
const rowSubData = subData?.find((frame) => frame.meta?.custom?.parentRowIndex === index);
|
||||||
@ -320,7 +366,7 @@ export const Table = memo((props: Props) => {
|
|||||||
return tableStyles.rowHeight;
|
return tableStyles.rowHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll: React.UIEventHandler = (event) => {
|
const handleScroll: UIEventHandler = (event) => {
|
||||||
const { scrollTop } = event.target as HTMLDivElement;
|
const { scrollTop } = event.target as HTMLDivElement;
|
||||||
|
|
||||||
if (listRef.current !== null) {
|
if (listRef.current !== null) {
|
||||||
@ -359,7 +405,11 @@ export const Table = memo((props: Props) => {
|
|||||||
<FooterRow
|
<FooterRow
|
||||||
isPaginationVisible={Boolean(enablePagination)}
|
isPaginationVisible={Boolean(enablePagination)}
|
||||||
footerValues={footerItems}
|
footerValues={footerItems}
|
||||||
footerGroups={footerGroups}
|
/*
|
||||||
|
The `headerGroups` and `footerGroups` objects destructured from the `useTable` hook are perfectly equivalent, in deep value, but not reference.
|
||||||
|
So we can use `headerGroups` here for building the footer, and no longer have a need for `footerGroups`.
|
||||||
|
*/
|
||||||
|
footerGroups={headerGroups}
|
||||||
totalColumnsWidth={totalColumnsWidth}
|
totalColumnsWidth={totalColumnsWidth}
|
||||||
tableStyles={tableStyles}
|
tableStyles={tableStyles}
|
||||||
/>
|
/>
|
||||||
|
@ -13,7 +13,7 @@ export interface Props {
|
|||||||
userProps?: object;
|
userProps?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TableCell: FC<Props> = ({ cell, tableStyles, onCellFilterAdded, columnIndex, columnCount, userProps }) => {
|
export const TableCell: FC<Props> = ({ cell, tableStyles, onCellFilterAdded, userProps }) => {
|
||||||
const cellProps = cell.getCellProps();
|
const cellProps = cell.getCellProps();
|
||||||
const field = (cell.column as unknown as GrafanaTableColumn).field;
|
const field = (cell.column as unknown as GrafanaTableColumn).field;
|
||||||
|
|
||||||
|
@ -65,10 +65,12 @@ export function useTableStateReducer({ onColumnResize, onSortByChange, data }: P
|
|||||||
|
|
||||||
export function getInitialState(
|
export function getInitialState(
|
||||||
initialSortBy: Props['initialSortBy'],
|
initialSortBy: Props['initialSortBy'],
|
||||||
|
initialShowRowNumbers: Props['showRowNums'],
|
||||||
columns: GrafanaTableColumn[]
|
columns: GrafanaTableColumn[]
|
||||||
): Partial<GrafanaTableState> {
|
): Partial<GrafanaTableState> {
|
||||||
const state: Partial<GrafanaTableState> = {
|
const state: Partial<GrafanaTableState> = {
|
||||||
toggleRowExpandedCounter: 0,
|
toggleRowExpandedCounter: 0,
|
||||||
|
hiddenColumns: initialShowRowNumbers ? [] : ['0'],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initialSortBy) {
|
if (initialSortBy) {
|
||||||
|
@ -76,6 +76,7 @@ export interface Props {
|
|||||||
noHeader?: boolean;
|
noHeader?: boolean;
|
||||||
showTypeIcons?: boolean;
|
showTypeIcons?: boolean;
|
||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
|
showRowNums?: boolean;
|
||||||
initialSortBy?: TableSortByFieldState[];
|
initialSortBy?: TableSortByFieldState[];
|
||||||
onColumnResize?: TableColumnResizeActionCallback;
|
onColumnResize?: TableColumnResizeActionCallback;
|
||||||
onSortByChange?: TableSortByActionCallback;
|
onSortByChange?: TableSortByActionCallback;
|
||||||
|
@ -12,6 +12,8 @@ import {
|
|||||||
sortNumber,
|
sortNumber,
|
||||||
sortOptions,
|
sortOptions,
|
||||||
valuesToOptions,
|
valuesToOptions,
|
||||||
|
buildBufferedEmptyValues,
|
||||||
|
buildFieldsForOptionalRowNums,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
function getData() {
|
function getData() {
|
||||||
@ -365,6 +367,30 @@ describe('Table utils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildBufferedEmptyValues', () => {
|
||||||
|
it('should build a buffered VectorArray of empty values the length of the number passed to it as an argument', () => {
|
||||||
|
const arrayVectorLength = 10;
|
||||||
|
const bufferedArray = buildBufferedEmptyValues(arrayVectorLength);
|
||||||
|
expect(bufferedArray).toBeInstanceOf(ArrayVector);
|
||||||
|
|
||||||
|
// Convert back into a standard array type.
|
||||||
|
const nonBufferedArray = Array.from(bufferedArray);
|
||||||
|
expect(nonBufferedArray[0]).toEqual(undefined);
|
||||||
|
expect(nonBufferedArray[nonBufferedArray.length - 1]).toEqual(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildFieldsForOptionalRowNums', () => {
|
||||||
|
it('should prepend a Field to a `DataFrame.field` so row numbers can be calculated and rendered', () => {
|
||||||
|
const builtField = buildFieldsForOptionalRowNums(10);
|
||||||
|
|
||||||
|
expect(builtField['name']).toEqual(' ');
|
||||||
|
expect(builtField['type']).toEqual(FieldType.string);
|
||||||
|
expect(typeof builtField['display']).toBe('function');
|
||||||
|
expect(typeof builtField['config']).toBe('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getFilteredOptions', () => {
|
describe('getFilteredOptions', () => {
|
||||||
describe('when called without filterValues', () => {
|
describe('when called without filterValues', () => {
|
||||||
it('then it should return an empty array', () => {
|
it('then it should return an empty array', () => {
|
||||||
|
@ -40,6 +40,7 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const EXPANDER_WIDTH = 50;
|
export const EXPANDER_WIDTH = 50;
|
||||||
|
export const OPTIONAL_ROW_NUMBER_COLUMN_WIDTH = 50;
|
||||||
|
|
||||||
export function getTextAlign(field?: Field): Property.JustifyContent {
|
export function getTextAlign(field?: Field): Property.JustifyContent {
|
||||||
if (!field) {
|
if (!field) {
|
||||||
@ -85,7 +86,7 @@ export function getColumns(
|
|||||||
Cell: RowExpander,
|
Cell: RowExpander,
|
||||||
width: EXPANDER_WIDTH,
|
width: EXPANDER_WIDTH,
|
||||||
minWidth: EXPANDER_WIDTH,
|
minWidth: EXPANDER_WIDTH,
|
||||||
filter: (rows: Row[], id: string, filterValues?: SelectableValue[]) => {
|
filter: (_rows: Row[], _id: string, _filterValues?: SelectableValue[]) => {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
justifyContent: 'left',
|
justifyContent: 'left',
|
||||||
@ -126,7 +127,7 @@ export function getColumns(
|
|||||||
id: fieldIndex.toString(),
|
id: fieldIndex.toString(),
|
||||||
field: field,
|
field: field,
|
||||||
Header: getFieldDisplayName(field, data),
|
Header: getFieldDisplayName(field, data),
|
||||||
accessor: (row: any, i: number) => {
|
accessor: (_row: any, i: number) => {
|
||||||
return field.values.get(i);
|
return field.values.get(i);
|
||||||
},
|
},
|
||||||
sortType: selectSortType(field.type),
|
sortType: selectSortType(field.type),
|
||||||
@ -162,6 +163,25 @@ export function getColumns(
|
|||||||
return columns;
|
return columns;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Build `Field` data for row numbers and prepend to the field array;
|
||||||
|
this way, on other column's sort, the row numbers will persist in their proper place.
|
||||||
|
*/
|
||||||
|
export function buildFieldsForOptionalRowNums(totalRows: number): Field {
|
||||||
|
return {
|
||||||
|
...defaultRowNumberColumnFieldData,
|
||||||
|
values: buildBufferedEmptyValues(totalRows),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This gives us an empty buffered ArrayVector of the desired length to match the table data.
|
||||||
|
It is simply a data placeholder for the Row Number column data.
|
||||||
|
*/
|
||||||
|
export function buildBufferedEmptyValues(totalRows: number): ArrayVector<string> {
|
||||||
|
return new ArrayVector(new Array(totalRows));
|
||||||
|
}
|
||||||
|
|
||||||
export function getCellComponent(displayMode: TableCellDisplayMode, field: Field): CellComponent {
|
export function getCellComponent(displayMode: TableCellDisplayMode, field: Field): CellComponent {
|
||||||
switch (displayMode) {
|
switch (displayMode) {
|
||||||
case TableCellDisplayMode.ColorText:
|
case TableCellDisplayMode.ColorText:
|
||||||
@ -299,20 +319,36 @@ function toNumber(value: any): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFooterItems(
|
export function getFooterItems(
|
||||||
filterFields: Array<{ field: Field }>,
|
filterFields: Array<{ id: string; field: Field }>,
|
||||||
values: any[number],
|
values: any[number],
|
||||||
options: TableFooterCalc,
|
options: TableFooterCalc,
|
||||||
theme2: GrafanaTheme2
|
theme2: GrafanaTheme2
|
||||||
): FooterItem[] {
|
): FooterItem[] {
|
||||||
|
/*
|
||||||
|
Here, `filterFields` is passed to as the `headerGroups[0].headers` array that was destrcutured from the `useTable` hook.
|
||||||
|
Unfortunately, since the `headerGroups` object is data based ONLY on the rendered "non-hidden" column headers,
|
||||||
|
it will NOT include the Row Number column if it has been toggled off. This will shift the rendering of the footer left 1 column,
|
||||||
|
creating an off-by-one issue. This is why we test for a `field.id` of "0". If the condition is truthy, the togglable Row Number column is being rendered,
|
||||||
|
and we can proceed normally. If not, we must add the field data in its place so that the footer data renders in the expected column.
|
||||||
|
*/
|
||||||
|
if (!filterFields.some((field) => field.id === '0')) {
|
||||||
|
const length = values.length;
|
||||||
|
// Build the additional field that will correct the off-by-one footer issue.
|
||||||
|
const fieldToAdd = { id: '0', field: buildFieldsForOptionalRowNums(length) };
|
||||||
|
filterFields = [fieldToAdd, ...filterFields];
|
||||||
|
}
|
||||||
|
|
||||||
return filterFields.map((data, i) => {
|
return filterFields.map((data, i) => {
|
||||||
if (data.field.type !== FieldType.number) {
|
if (data.field.type !== FieldType.number) {
|
||||||
// show the reducer in the first column
|
// Show the reducer type ("Total", "Range", "Count", "Delta", etc) in the first non "Row Number" column, only if it cannot be numerically reduced.
|
||||||
if (i === 0 && options.reducer && options.reducer.length > 0) {
|
if (i === 1 && options.reducer && options.reducer.length > 0) {
|
||||||
const reducer = fieldReducers.get(options.reducer[0]);
|
const reducer = fieldReducers.get(options.reducer[0]);
|
||||||
return reducer.name;
|
return reducer.name;
|
||||||
}
|
}
|
||||||
|
// Otherwise return `undefined`, which will render an <EmptyCell />.
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newField = clone(data.field);
|
let newField = clone(data.field);
|
||||||
newField.values = new ArrayVector(values[i]);
|
newField.values = new ArrayVector(values[i]);
|
||||||
newField.state = undefined;
|
newField.state = undefined;
|
||||||
@ -336,6 +372,7 @@ function getFormattedValue(field: Field, reducer: string[], theme: GrafanaTheme2
|
|||||||
return formattedValueToString(fmt(v));
|
return formattedValueToString(fmt(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This strips the raw vales from the `rows` object.
|
||||||
export function createFooterCalculationValues(rows: Row[]): any[number] {
|
export function createFooterCalculationValues(rows: Row[]): any[number] {
|
||||||
const values: any[number] = [];
|
const values: any[number] = [];
|
||||||
|
|
||||||
@ -412,3 +449,31 @@ export function migrateTableDisplayModeToCellOptions(displayMode: TableCellDispl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
For building the column data for the togglable Row Number field.
|
||||||
|
`values` property is omitted, as it will be added at a later time.
|
||||||
|
*/
|
||||||
|
export const defaultRowNumberColumnFieldData: Omit<Field, 'values'> = {
|
||||||
|
/*
|
||||||
|
Single whitespace as value for `name` property so as to render an empty/invisible column header;
|
||||||
|
without the single whitespace, falsey headers (empty strings) are given a default name of "Value".
|
||||||
|
*/
|
||||||
|
name: ' ',
|
||||||
|
display: function (value: string) {
|
||||||
|
return {
|
||||||
|
numeric: Number(value),
|
||||||
|
text: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
type: FieldType.string,
|
||||||
|
config: {
|
||||||
|
color: { mode: 'thresholds' },
|
||||||
|
custom: {
|
||||||
|
align: 'auto',
|
||||||
|
cellOptions: { type: 'auto' },
|
||||||
|
inspect: false,
|
||||||
|
width: OPTIONAL_ROW_NUMBER_COLUMN_WIDTH,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
@ -5,6 +5,7 @@ import { DataFrame, FieldMatcherID, getFrameDisplayName, PanelProps, SelectableV
|
|||||||
import { PanelDataErrorView } from '@grafana/runtime';
|
import { PanelDataErrorView } from '@grafana/runtime';
|
||||||
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
|
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
|
||||||
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
|
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
|
||||||
|
import { OPTIONAL_ROW_NUMBER_COLUMN_WIDTH } from '@grafana/ui/src/components/Table/utils';
|
||||||
|
|
||||||
import { PanelOptions } from './models.gen';
|
import { PanelOptions } from './models.gen';
|
||||||
|
|
||||||
@ -18,7 +19,7 @@ export function TablePanel(props: Props) {
|
|||||||
const frames = data.series;
|
const frames = data.series;
|
||||||
const mainFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex === undefined);
|
const mainFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex === undefined);
|
||||||
const subFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex !== undefined);
|
const subFrames = frames.filter((f) => f.meta?.custom?.parentRowIndex !== undefined);
|
||||||
const count = mainFrames?.length;
|
const count = mainFrames.length;
|
||||||
const hasFields = mainFrames[0]?.fields.length;
|
const hasFields = mainFrames[0]?.fields.length;
|
||||||
const currentIndex = getCurrentFrameIndex(mainFrames, options);
|
const currentIndex = getCurrentFrameIndex(mainFrames, options);
|
||||||
const main = mainFrames[currentIndex];
|
const main = mainFrames[currentIndex];
|
||||||
@ -41,14 +42,16 @@ export function TablePanel(props: Props) {
|
|||||||
const tableElement = (
|
const tableElement = (
|
||||||
<Table
|
<Table
|
||||||
height={tableHeight}
|
height={tableHeight}
|
||||||
width={width}
|
// This calculation is to accommodate the optionally rendered Row Numbers Column
|
||||||
|
width={options.showRowNums ? width : width + OPTIONAL_ROW_NUMBER_COLUMN_WIDTH}
|
||||||
data={main}
|
data={main}
|
||||||
noHeader={!options.showHeader}
|
noHeader={!options.showHeader}
|
||||||
showTypeIcons={options.showTypeIcons}
|
showTypeIcons={options.showTypeIcons}
|
||||||
resizable={true}
|
resizable={true}
|
||||||
|
showRowNums={options.showRowNums}
|
||||||
initialSortBy={options.sortBy}
|
initialSortBy={options.sortBy}
|
||||||
onSortByChange={(sortBy) => onSortByChange(sortBy, props)}
|
onSortByChange={(sortBy) => onSortByChange(sortBy, props)}
|
||||||
onColumnResize={(displayName, width) => onColumnResize(displayName, width, props)}
|
onColumnResize={(displayName, resizedWidth) => onColumnResize(displayName, resizedWidth, props)}
|
||||||
onCellFilterAdded={panelContext.onAddAdHocFilter}
|
onCellFilterAdded={panelContext.onAddAdHocFilter}
|
||||||
footerOptions={options.footer}
|
footerOptions={options.footer}
|
||||||
enablePagination={options.footer?.enablePagination}
|
enablePagination={options.footer?.enablePagination}
|
||||||
|
@ -15,6 +15,7 @@ export const modelVersion = Object.freeze([1, 0]);
|
|||||||
export interface PanelOptions {
|
export interface PanelOptions {
|
||||||
frameIndex: number;
|
frameIndex: number;
|
||||||
showHeader: boolean;
|
showHeader: boolean;
|
||||||
|
showRowNums?: boolean;
|
||||||
showTypeIcons?: boolean;
|
showTypeIcons?: boolean;
|
||||||
sortBy?: TableSortByFieldState[];
|
sortBy?: TableSortByFieldState[];
|
||||||
footer?: TableFooterCalc; // TODO: should be array (options builder is limited)
|
footer?: TableFooterCalc; // TODO: should be array (options builder is limited)
|
||||||
@ -23,6 +24,7 @@ export interface PanelOptions {
|
|||||||
export const defaultPanelOptions: PanelOptions = {
|
export const defaultPanelOptions: PanelOptions = {
|
||||||
frameIndex: 0,
|
frameIndex: 0,
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
|
showRowNums: false,
|
||||||
showTypeIcons: false,
|
showTypeIcons: false,
|
||||||
footer: {
|
footer: {
|
||||||
show: false,
|
show: false,
|
||||||
|
@ -108,6 +108,11 @@ export const plugin = new PanelPlugin<PanelOptions, TableFieldOptions>(TablePane
|
|||||||
name: 'Show table header',
|
name: 'Show table header',
|
||||||
defaultValue: defaultPanelOptions.showHeader,
|
defaultValue: defaultPanelOptions.showHeader,
|
||||||
})
|
})
|
||||||
|
.addBooleanSwitch({
|
||||||
|
path: 'showRowNums',
|
||||||
|
name: 'Show row numbers',
|
||||||
|
defaultValue: defaultPanelOptions.showRowNums,
|
||||||
|
})
|
||||||
.addBooleanSwitch({
|
.addBooleanSwitch({
|
||||||
path: 'footer.show',
|
path: 'footer.show',
|
||||||
category: [footerCategory],
|
category: [footerCategory],
|
||||||
|
@ -28,6 +28,7 @@ composableKinds: PanelCfg: {
|
|||||||
frameIndex: number | *0
|
frameIndex: number | *0
|
||||||
showHeader: bool | *true
|
showHeader: bool | *true
|
||||||
showTypeIcons: bool | *false
|
showTypeIcons: bool | *false
|
||||||
|
showRowNums?: bool | *false
|
||||||
sortBy?: [...common.TableSortByFieldState]
|
sortBy?: [...common.TableSortByFieldState]
|
||||||
} @cuetsy(kind="interface")
|
} @cuetsy(kind="interface")
|
||||||
PanelFieldConfig: common.TableFieldOptions & {} @cuetsy(kind="interface")
|
PanelFieldConfig: common.TableFieldOptions & {} @cuetsy(kind="interface")
|
||||||
|
Loading…
Reference in New Issue
Block a user