mirror of
https://github.com/grafana/grafana.git
synced 2025-08-06 20:59:35 +08:00
TableNG: Simplify row height calculation and make more extensible (#108624)
* TableNG: Simplify row height calculation and make more extensible * add a cache for the results of rowHeight when it's a function * JSDoc comment for util * from the other branch, copy the related code and tests * rework the line counters a bit, limit line counting to string fields * add test for string case for buildRowLineCounters * add the concept of estimates vs. counts * add a comment * ceil, not floor * try to be as terse as possible * test for estimates * comment the type * more comment in test * swap * fix #108804 * convert em letter spacing to px for avgCharWidth calculation * tweak whee em-to-px math happens, and force count to occur on every row when wrap is on to avoid short row issues * update test * update to clamp single-line estimation using a hardcoded value (0.85) * add assertion for not calling counter in that case * uwrap 0.1.2 * fix betterer issues * fix typography ctx extra import --------- Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
@ -131,7 +131,7 @@
|
||||
"tslib": "2.8.1",
|
||||
"uplot": "1.6.32",
|
||||
"uuid": "11.1.0",
|
||||
"uwrap": "0.1.1"
|
||||
"uwrap": "0.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.0",
|
||||
|
@ -8,11 +8,8 @@ import { TableCellDisplayMode } from '../../types';
|
||||
import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
|
||||
import { ImageCellProps } from '../types';
|
||||
|
||||
const DATALINKS_HEIGHT_OFFSET = 10;
|
||||
|
||||
export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => {
|
||||
const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET;
|
||||
const styles = useStyles2(getStyles, calculatedHeight, justifyContent);
|
||||
const styles = useStyles2(getStyles, height, justifyContent);
|
||||
|
||||
const { text } = field.display!(value);
|
||||
const { alt, title } =
|
||||
@ -27,7 +24,7 @@ export const ImageCell = ({ cellOptions, field, height, justifyContent, value, r
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
|
||||
const getStyles = (_theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
|
||||
image: css({
|
||||
height,
|
||||
width: 'auto',
|
||||
|
@ -26,7 +26,7 @@ import {
|
||||
ReducerID,
|
||||
} from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { FieldColorModeId, TableCellHeight } from '@grafana/schema';
|
||||
import { FieldColorModeId } from '@grafana/schema';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
|
||||
import { ContextMenu } from '../../ContextMenu/ContextMenu';
|
||||
@ -52,29 +52,30 @@ import {
|
||||
useRowHeight,
|
||||
useScrollbarWidth,
|
||||
useSortedRows,
|
||||
useTypographyCtx,
|
||||
} from './hooks';
|
||||
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps } from './types';
|
||||
import {
|
||||
applySort,
|
||||
computeColWidths,
|
||||
createTypographyContext,
|
||||
displayJsonValue,
|
||||
extractPixelValue,
|
||||
frameToRecords,
|
||||
getAlignment,
|
||||
getApplyToRowBgFn,
|
||||
getCellColors,
|
||||
getCellLinks,
|
||||
getCellOptions,
|
||||
getDefaultRowHeight,
|
||||
getDisplayName,
|
||||
getIsNestedTable,
|
||||
getVisibleFields,
|
||||
shouldTextOverflow,
|
||||
getApplyToRowBgFn,
|
||||
computeColWidths,
|
||||
applySort,
|
||||
getCellColors,
|
||||
getCellOptions,
|
||||
shouldTextWrap,
|
||||
isCellInspectEnabled,
|
||||
getCellLinks,
|
||||
withDataLinksActionsTooltip,
|
||||
displayJsonValue,
|
||||
getAlignment,
|
||||
getJustifyContent,
|
||||
getVisibleFields,
|
||||
isCellInspectEnabled,
|
||||
shouldTextOverflow,
|
||||
shouldTextWrap,
|
||||
TextAlign,
|
||||
withDataLinksActionsTooltip,
|
||||
} from './utils';
|
||||
|
||||
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
|
||||
@ -160,7 +161,6 @@ export function TableNG(props: TableNGProps) {
|
||||
} = useSortedRows(filteredRows, data.fields, { hasNestedFrames, initialSortBy });
|
||||
|
||||
const defaultRowHeight = getDefaultRowHeight(theme, cellHeight);
|
||||
const defaultHeaderHeight = getDefaultRowHeight(theme, TableCellHeight.Sm);
|
||||
const [isInspecting, setIsInspecting] = useState(false);
|
||||
const [expandedRows, setExpandedRows] = useState(() => new Set<number>());
|
||||
|
||||
@ -172,13 +172,20 @@ export function TableNG(props: TableNGProps) {
|
||||
() => (hasNestedFrames ? width - COLUMN.EXPANDER_WIDTH : width) - scrollbarWidth,
|
||||
[width, hasNestedFrames, scrollbarWidth]
|
||||
);
|
||||
const typographyCtx = useTypographyCtx();
|
||||
const typographyCtx = useMemo(
|
||||
() =>
|
||||
createTypographyContext(
|
||||
theme.typography.fontSize,
|
||||
theme.typography.fontFamily,
|
||||
extractPixelValue(theme.typography.body.letterSpacing!) * theme.typography.fontSize
|
||||
),
|
||||
[theme]
|
||||
);
|
||||
const widths = useMemo(() => computeColWidths(visibleFields, availableWidth), [visibleFields, availableWidth]);
|
||||
const headerHeight = useHeaderHeight({
|
||||
columnWidths: widths,
|
||||
fields: visibleFields,
|
||||
enabled: hasHeader,
|
||||
defaultHeight: defaultHeaderHeight,
|
||||
sortColumns,
|
||||
showTypeIcons: showTypeIcons ?? false,
|
||||
typographyCtx,
|
||||
@ -285,7 +292,6 @@ export function TableNG(props: TableNGProps) {
|
||||
};
|
||||
|
||||
let lastRowIdx = -1;
|
||||
let _rowHeight = 0;
|
||||
// shared when whole row will be styled by a single cell's color
|
||||
let rowCellStyle: Partial<CSSProperties> = {
|
||||
color: undefined,
|
||||
@ -381,7 +387,6 @@ export function TableNG(props: TableNGProps) {
|
||||
|
||||
// meh, this should be cached by the renderRow() call?
|
||||
if (rowIdx !== lastRowIdx) {
|
||||
_rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
|
||||
lastRowIdx = rowIdx;
|
||||
|
||||
rowCellStyle.color = undefined;
|
||||
@ -420,6 +425,9 @@ export function TableNG(props: TableNGProps) {
|
||||
const renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
|
||||
const rowIdx = props.row.__index;
|
||||
const value = props.row[props.column.key];
|
||||
// TODO: it would be nice to get rid of passing height down as a prop. but this value
|
||||
// is cached so the cost of calling for every cell is low.
|
||||
const height = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
|
||||
const frame = data;
|
||||
|
||||
return (
|
||||
@ -428,7 +436,7 @@ export function TableNG(props: TableNGProps) {
|
||||
cellOptions,
|
||||
frame,
|
||||
field,
|
||||
height: _rowHeight,
|
||||
height,
|
||||
justifyContent,
|
||||
rowIdx,
|
||||
theme,
|
||||
@ -580,7 +588,7 @@ export function TableNG(props: TableNGProps) {
|
||||
{...commonDataGridProps}
|
||||
className={clsx(styles.grid, styles.gridNested)}
|
||||
headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
|
||||
headerRowHeight={hasNestedHeaders ? defaultHeaderHeight : 0}
|
||||
headerRowHeight={hasNestedHeaders ? TABLE.HEADER_HEIGHT : 0}
|
||||
columns={nestedColumns}
|
||||
rows={expandedRecords}
|
||||
renderers={{ renderRow, renderCell: renderCellRoot }}
|
||||
@ -599,7 +607,6 @@ export function TableNG(props: TableNGProps) {
|
||||
crossFilterOrder,
|
||||
crossFilterRows,
|
||||
data,
|
||||
defaultHeaderHeight,
|
||||
defaultRowHeight,
|
||||
enableSharedCrosshair,
|
||||
expandedRows,
|
||||
|
@ -13,7 +13,9 @@ export const TABLE = {
|
||||
PAGINATION_LIMIT: 750,
|
||||
SCROLL_BAR_WIDTH: 8,
|
||||
SCROLL_BAR_MARGIN: 2,
|
||||
FONT_SIZE: 14,
|
||||
LINE_HEIGHT: 22,
|
||||
HEADER_HEIGHT: 28,
|
||||
NESTED_NO_DATA_HEIGHT: 60,
|
||||
BORDER_RIGHT: 0.666667,
|
||||
BORDER_RIGHT: 1,
|
||||
};
|
||||
|
@ -1,23 +1,19 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { varPreLine } from 'uwrap';
|
||||
|
||||
import { cacheFieldDisplayNames, createDataFrame, Field, FieldType } from '@grafana/data';
|
||||
import { TableCellDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { TABLE } from './constants';
|
||||
import {
|
||||
useFilteredRows,
|
||||
usePaginatedRows,
|
||||
useSortedRows,
|
||||
useFooterCalcs,
|
||||
useHeaderHeight,
|
||||
useTypographyCtx,
|
||||
useRowHeight,
|
||||
} from './hooks';
|
||||
|
||||
jest.mock('uwrap', () => ({
|
||||
// ...jest.requireActual('uwrap'),
|
||||
varPreLine: jest.fn(() => ({
|
||||
count: jest.fn(() => 1),
|
||||
})),
|
||||
}));
|
||||
import { TableRow } from './types';
|
||||
import { createTypographyContext } from './utils';
|
||||
|
||||
describe('TableNG hooks', () => {
|
||||
function setupData() {
|
||||
@ -28,21 +24,21 @@ describe('TableNG hooks', () => {
|
||||
type: FieldType.string,
|
||||
display: (v) => ({ text: v as string, numeric: NaN }),
|
||||
config: {},
|
||||
values: [],
|
||||
values: ['Alice', 'Bob', 'Charlie'],
|
||||
},
|
||||
{
|
||||
name: 'age',
|
||||
type: FieldType.number,
|
||||
display: (v) => ({ text: (v as number).toString(), numeric: v as number }),
|
||||
config: {},
|
||||
values: [],
|
||||
values: [30, 25, 35],
|
||||
},
|
||||
{
|
||||
name: 'active',
|
||||
type: FieldType.boolean,
|
||||
display: (v) => ({ text: (v as boolean).toString(), numeric: NaN }),
|
||||
config: {},
|
||||
values: [],
|
||||
values: [true, false, true],
|
||||
},
|
||||
];
|
||||
|
||||
@ -149,7 +145,7 @@ describe('TableNG hooks', () => {
|
||||
height: 300,
|
||||
width: 800,
|
||||
enabled: false,
|
||||
headerHeight: 28,
|
||||
headerHeight: TABLE.HEADER_HEIGHT,
|
||||
footerHeight: 0,
|
||||
})
|
||||
);
|
||||
@ -201,7 +197,7 @@ describe('TableNG hooks', () => {
|
||||
height: 140,
|
||||
width: 800,
|
||||
rowHeight: 10,
|
||||
headerHeight: 28,
|
||||
headerHeight: TABLE.HEADER_HEIGHT,
|
||||
footerHeight: 45,
|
||||
})
|
||||
);
|
||||
@ -429,16 +425,16 @@ describe('TableNG hooks', () => {
|
||||
});
|
||||
|
||||
describe('useHeaderHeight', () => {
|
||||
const typographyCtx = createTypographyContext(14, 'sans-serif');
|
||||
|
||||
it('should return 0 when no header is present', () => {
|
||||
const { fields } = setupData();
|
||||
const { result } = renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields,
|
||||
columnWidths: [],
|
||||
enabled: false,
|
||||
typographyCtx,
|
||||
defaultHeight: 28,
|
||||
sortColumns: [],
|
||||
});
|
||||
});
|
||||
@ -448,31 +444,20 @@ describe('TableNG hooks', () => {
|
||||
it('should return the default height when wrap is disabled', () => {
|
||||
const { fields } = setupData();
|
||||
const { result } = renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields,
|
||||
columnWidths: [],
|
||||
enabled: true,
|
||||
typographyCtx,
|
||||
defaultHeight: 28,
|
||||
sortColumns: [],
|
||||
});
|
||||
});
|
||||
expect(result.current).toBe(22);
|
||||
expect(result.current).toBe(28);
|
||||
});
|
||||
|
||||
it('should return the appropriate height for wrapped text', () => {
|
||||
// Simulate 2 lines of text
|
||||
jest.mocked(varPreLine).mockReturnValue({
|
||||
count: jest.fn(() => 2),
|
||||
each: jest.fn(),
|
||||
split: jest.fn(),
|
||||
test: jest.fn(),
|
||||
});
|
||||
|
||||
const { fields } = setupData();
|
||||
const { result } = renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields: fields.map((field) => {
|
||||
if (field.name === 'name') {
|
||||
@ -492,8 +477,7 @@ describe('TableNG hooks', () => {
|
||||
}),
|
||||
columnWidths: [100, 100, 100],
|
||||
enabled: true,
|
||||
typographyCtx: { ...typographyCtx, avgCharWidth: 5 },
|
||||
defaultHeight: 28,
|
||||
typographyCtx: { ...typographyCtx, avgCharWidth: 5, wrappedCount: jest.fn(() => 2) },
|
||||
sortColumns: [],
|
||||
});
|
||||
});
|
||||
@ -504,19 +488,9 @@ describe('TableNG hooks', () => {
|
||||
it('should calculate the available width for a header cell based on the icons rendered within it', () => {
|
||||
const countFn = jest.fn(() => 1);
|
||||
|
||||
// Simulate 2 lines of text
|
||||
jest.mocked(varPreLine).mockReturnValue({
|
||||
count: countFn,
|
||||
each: jest.fn(),
|
||||
split: jest.fn(),
|
||||
test: jest.fn(),
|
||||
});
|
||||
|
||||
const { fields } = setupData();
|
||||
|
||||
renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
|
||||
return useHeaderHeight({
|
||||
fields: fields.map((field) => {
|
||||
if (field.name === 'name') {
|
||||
@ -536,17 +510,15 @@ describe('TableNG hooks', () => {
|
||||
}),
|
||||
columnWidths: [100, 100, 100],
|
||||
enabled: true,
|
||||
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
|
||||
defaultHeight: 28,
|
||||
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
|
||||
sortColumns: [],
|
||||
showTypeIcons: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 87);
|
||||
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 86);
|
||||
|
||||
renderHook(() => {
|
||||
const typographyCtx = useTypographyCtx();
|
||||
return useHeaderHeight({
|
||||
fields: fields.map((field) => {
|
||||
if (field.name === 'name') {
|
||||
@ -567,14 +539,233 @@ describe('TableNG hooks', () => {
|
||||
}),
|
||||
columnWidths: [100, 100, 100],
|
||||
enabled: true,
|
||||
typographyCtx: { ...typographyCtx, avgCharWidth: 10 },
|
||||
defaultHeight: 28,
|
||||
typographyCtx: { ...typographyCtx, wrappedCount: countFn },
|
||||
sortColumns: [{ columnKey: 'Longer name that needs wrapping', direction: 'ASC' }],
|
||||
showTypeIcons: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 27);
|
||||
expect(countFn).toHaveBeenCalledWith('Longer name that needs wrapping', 26);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRowHeight', () => {
|
||||
const typographyCtx = createTypographyContext(14, 'sans-serif');
|
||||
|
||||
it('returns the default height if there are no wrapped columns or nested frames', () => {
|
||||
const { fields } = setupData();
|
||||
|
||||
const defaultHeight = 40;
|
||||
|
||||
expect(
|
||||
renderHook(() => {
|
||||
return useRowHeight({
|
||||
fields,
|
||||
columnWidths: [100, 100, 100],
|
||||
defaultHeight,
|
||||
typographyCtx: typographyCtx,
|
||||
hasNestedFrames: false,
|
||||
expandedRows: new Set(),
|
||||
});
|
||||
}).result.current
|
||||
).toBe(defaultHeight);
|
||||
});
|
||||
|
||||
describe('nested frames', () => {
|
||||
it('returns 0 if the parent row is not expanded', () => {
|
||||
const { fields } = setupData();
|
||||
|
||||
expect(
|
||||
renderHook(() => {
|
||||
const rowHeight = useRowHeight({
|
||||
fields: [
|
||||
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
|
||||
],
|
||||
columnWidths: [100, 100, 100],
|
||||
defaultHeight: 40,
|
||||
typographyCtx: typographyCtx,
|
||||
hasNestedFrames: true,
|
||||
expandedRows: new Set(),
|
||||
});
|
||||
if (typeof rowHeight !== 'function') {
|
||||
throw new Error('Expected rowHeight to be a function');
|
||||
}
|
||||
return rowHeight({ __depth: 1, data: createDataFrame({ fields }), __index: 0 });
|
||||
}).result.current
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('returns a static height if there are no rows in the nested frame', () => {
|
||||
const { fields } = setupData();
|
||||
|
||||
expect(
|
||||
renderHook(() => {
|
||||
const rowHeight = useRowHeight({
|
||||
fields: [
|
||||
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
|
||||
],
|
||||
columnWidths: [100, 100, 100],
|
||||
defaultHeight: 40,
|
||||
typographyCtx: typographyCtx,
|
||||
hasNestedFrames: true,
|
||||
expandedRows: new Set([0]),
|
||||
});
|
||||
if (typeof rowHeight !== 'function') {
|
||||
throw new Error('Expected rowHeight to be a function');
|
||||
}
|
||||
return rowHeight({
|
||||
__depth: 1,
|
||||
data: undefined,
|
||||
__index: 0,
|
||||
});
|
||||
}).result.current
|
||||
).toBe(TABLE.NESTED_NO_DATA_HEIGHT + TABLE.CELL_PADDING * 2);
|
||||
});
|
||||
|
||||
it('calculates the height to return based on the number of rows in the nested frame', () => {
|
||||
const { fields } = setupData();
|
||||
|
||||
const defaultHeight = 40;
|
||||
|
||||
expect(
|
||||
renderHook(() => {
|
||||
const rowHeight = useRowHeight({
|
||||
fields: [
|
||||
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
|
||||
],
|
||||
columnWidths: [100, 100, 100],
|
||||
defaultHeight,
|
||||
typographyCtx: typographyCtx,
|
||||
hasNestedFrames: true,
|
||||
expandedRows: new Set([0]),
|
||||
});
|
||||
if (typeof rowHeight !== 'function') {
|
||||
throw new Error('Expected rowHeight to be a function');
|
||||
}
|
||||
return rowHeight({
|
||||
__index: 0,
|
||||
__depth: 1,
|
||||
data: createDataFrame({ fields }),
|
||||
});
|
||||
}).result.current
|
||||
).toBe(defaultHeight * 4 + TABLE.CELL_PADDING * 2); // 3 rows + header + padding
|
||||
});
|
||||
|
||||
it('removes the header if configured', () => {
|
||||
const { fields } = setupData();
|
||||
|
||||
const defaultHeight = 40;
|
||||
|
||||
expect(
|
||||
renderHook(() => {
|
||||
const rowHeight = useRowHeight({
|
||||
fields: [
|
||||
{ name: 'nested', type: FieldType.nestedFrames, values: [createDataFrame({ fields })], config: {} },
|
||||
],
|
||||
columnWidths: [100, 100, 100],
|
||||
defaultHeight,
|
||||
typographyCtx: typographyCtx,
|
||||
hasNestedFrames: true,
|
||||
expandedRows: new Set([0]),
|
||||
});
|
||||
if (typeof rowHeight !== 'function') {
|
||||
throw new Error('Expected rowHeight to be a function');
|
||||
}
|
||||
return rowHeight({
|
||||
__index: 0,
|
||||
__depth: 1,
|
||||
data: createDataFrame({ fields, meta: { custom: { noHeader: true } } }),
|
||||
});
|
||||
}).result.current
|
||||
).toBe(defaultHeight * 3 + TABLE.CELL_PADDING * 2); // 3 rows + padding (no header)
|
||||
});
|
||||
});
|
||||
|
||||
// we test the lineCounters and getRowHeight directly to check that all of that
|
||||
// math is working correctly. we mainly want to confirm here that the
|
||||
// cache is clearing and that the local logic in this hook works.
|
||||
describe('wrapped columns', () => {
|
||||
let rows: TableRow[];
|
||||
let fieldsWithWrappedText: Field[];
|
||||
|
||||
beforeEach(() => {
|
||||
const { fields, rows: _rows } = setupData();
|
||||
|
||||
rows = _rows;
|
||||
fieldsWithWrappedText = fields.map((field) => {
|
||||
if (field.name === 'name') {
|
||||
return {
|
||||
...field,
|
||||
name: 'Longer name that needs wrapping',
|
||||
config: {
|
||||
...field.config,
|
||||
custom: {
|
||||
...field.config?.custom,
|
||||
cellOptions: {
|
||||
cellType: TableCellDisplayMode.Auto,
|
||||
wrapText: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
});
|
||||
|
||||
it('handles changes to default height on re-render', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ defaultHeight }) => {
|
||||
const rowHeight = useRowHeight({
|
||||
fields: fieldsWithWrappedText,
|
||||
columnWidths: [100, 100, 100],
|
||||
defaultHeight,
|
||||
typographyCtx: typographyCtx,
|
||||
hasNestedFrames: false,
|
||||
expandedRows: new Set(),
|
||||
});
|
||||
if (typeof rowHeight !== 'function') {
|
||||
throw new Error('Expected rowHeight to be a function');
|
||||
}
|
||||
return rowHeight;
|
||||
},
|
||||
{
|
||||
initialProps: { defaultHeight: 40 },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.current(rows[0])).toBe(40);
|
||||
|
||||
// change the column widths
|
||||
rerender({ defaultHeight: 50 });
|
||||
|
||||
expect(result.current(rows[0])).toBe(50);
|
||||
});
|
||||
|
||||
it('adjusts the width of the columns based on the cell padding and border', () => {
|
||||
fieldsWithWrappedText[0].values[0] = 'Annie Lennox';
|
||||
|
||||
const wrappedCountFn = jest.fn(() => 2);
|
||||
const estimateLinesFn = jest.fn(() => 2);
|
||||
const { result } = renderHook(() => {
|
||||
const rowHeight = useRowHeight({
|
||||
fields: fieldsWithWrappedText,
|
||||
columnWidths: [100, 100, 100],
|
||||
defaultHeight: 40,
|
||||
typographyCtx: { ...typographyCtx, wrappedCount: wrappedCountFn, estimateLines: estimateLinesFn },
|
||||
hasNestedFrames: false,
|
||||
expandedRows: new Set(),
|
||||
});
|
||||
if (typeof rowHeight !== 'function') {
|
||||
throw new Error('Expected rowHeight to be a function');
|
||||
}
|
||||
return rowHeight;
|
||||
});
|
||||
|
||||
expect(result.current(rows[0])).toEqual(expect.any(Number));
|
||||
|
||||
expect(estimateLinesFn).toHaveBeenCalledWith('Annie Lennox', 100 - TABLE.CELL_PADDING * 2 - TABLE.BORDER_RIGHT);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,22 +1,20 @@
|
||||
import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect, RefObject } from 'react';
|
||||
import { Column, DataGridHandle, DataGridProps, SortColumn } from 'react-data-grid';
|
||||
import { varPreLine } from 'uwrap';
|
||||
|
||||
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
|
||||
|
||||
import { useTheme2 } from '../../../themes/ThemeContext';
|
||||
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types';
|
||||
import { TableColumnResizeActionCallback } from '../types';
|
||||
|
||||
import { TABLE } from './constants';
|
||||
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow } from './types';
|
||||
import { FilterType, TableFooterCalc, TableRow, TableSortByFieldState, TableSummaryRow, TypographyCtx } from './types';
|
||||
import {
|
||||
getDisplayName,
|
||||
processNestedTableRows,
|
||||
applySort,
|
||||
getCellOptions,
|
||||
getColumnTypes,
|
||||
GetMaxWrapCellOptions,
|
||||
getMaxWrapCell,
|
||||
getRowHeight,
|
||||
buildHeaderLineCounters,
|
||||
buildRowLineCounters,
|
||||
} from './utils';
|
||||
|
||||
// Helper function to get displayed value
|
||||
@ -314,49 +312,6 @@ export function useFooterCalcs(
|
||||
}, [fields, enabled, footerOptions, isCountRowsSet, rows]);
|
||||
}
|
||||
|
||||
interface TypographyCtx {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
font: string;
|
||||
avgCharWidth: number;
|
||||
calcRowHeight: (text: string, cellWidth: number, defaultHeight: number) => number;
|
||||
}
|
||||
|
||||
export function useTypographyCtx(): TypographyCtx {
|
||||
const theme = useTheme2();
|
||||
const typographyCtx = useMemo((): TypographyCtx => {
|
||||
const font = `${theme.typography.fontSize}px ${theme.typography.fontFamily}`;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
// set in grafana/data in createTypography.ts
|
||||
const letterSpacing = 0.15;
|
||||
|
||||
ctx.letterSpacing = `${letterSpacing}px`;
|
||||
ctx.font = font;
|
||||
const txt =
|
||||
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s";
|
||||
const txtWidth = ctx.measureText(txt).width;
|
||||
const avgCharWidth = txtWidth / txt.length + letterSpacing;
|
||||
const { count } = varPreLine(ctx);
|
||||
|
||||
const calcRowHeight = (text: string, cellWidth: number, defaultHeight: number) => {
|
||||
if (text === '') {
|
||||
return defaultHeight;
|
||||
}
|
||||
const numLines = count(text, cellWidth);
|
||||
const totalHeight = numLines * TABLE.LINE_HEIGHT + 2 * TABLE.CELL_PADDING;
|
||||
return Math.max(totalHeight, defaultHeight);
|
||||
};
|
||||
|
||||
return {
|
||||
calcRowHeight,
|
||||
ctx,
|
||||
font,
|
||||
avgCharWidth,
|
||||
};
|
||||
}, [theme.typography.fontSize, theme.typography.fontFamily]);
|
||||
return typographyCtx;
|
||||
}
|
||||
|
||||
const ICON_WIDTH = 16;
|
||||
const ICON_GAP = 4;
|
||||
|
||||
@ -364,7 +319,6 @@ interface UseHeaderHeightOptions {
|
||||
enabled: boolean;
|
||||
fields: Field[];
|
||||
columnWidths: number[];
|
||||
defaultHeight: number;
|
||||
sortColumns: SortColumn[];
|
||||
typographyCtx: TypographyCtx;
|
||||
showTypeIcons?: boolean;
|
||||
@ -374,12 +328,14 @@ export function useHeaderHeight({
|
||||
fields,
|
||||
enabled,
|
||||
columnWidths,
|
||||
defaultHeight,
|
||||
sortColumns,
|
||||
typographyCtx: { calcRowHeight, avgCharWidth },
|
||||
typographyCtx,
|
||||
showTypeIcons = false,
|
||||
}: UseHeaderHeightOptions): number {
|
||||
const perIconSpace = ICON_WIDTH + ICON_GAP;
|
||||
|
||||
const lineCounters = useMemo(() => buildHeaderLineCounters(fields, typographyCtx), [fields, typographyCtx]);
|
||||
|
||||
const columnAvailableWidths = useMemo(
|
||||
() =>
|
||||
columnWidths.map((c, idx) => {
|
||||
@ -396,46 +352,26 @@ export function useHeaderHeight({
|
||||
if (showTypeIcons) {
|
||||
width -= perIconSpace;
|
||||
}
|
||||
return Math.floor(width);
|
||||
// sadly, the math for this is off by exactly 1 pixel. shrug.
|
||||
return Math.floor(width) - 1;
|
||||
}),
|
||||
[fields, columnWidths, sortColumns, showTypeIcons, perIconSpace]
|
||||
);
|
||||
|
||||
const [wrappedColHeaderIdxs, hasWrappedColHeaders] = useMemo(() => {
|
||||
let hasWrappedColHeaders = false;
|
||||
return [
|
||||
fields.map((field) => {
|
||||
const wrapText = field.config?.custom?.wrapHeaderText ?? false;
|
||||
if (wrapText) {
|
||||
hasWrappedColHeaders = true;
|
||||
}
|
||||
return wrapText;
|
||||
}),
|
||||
hasWrappedColHeaders,
|
||||
];
|
||||
}, [fields]);
|
||||
|
||||
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
|
||||
() => ({
|
||||
colWidths: columnAvailableWidths,
|
||||
avgCharWidth,
|
||||
wrappedColIdxs: wrappedColHeaderIdxs,
|
||||
}),
|
||||
[columnAvailableWidths, avgCharWidth, wrappedColHeaderIdxs]
|
||||
);
|
||||
|
||||
// TODO: is there a less clunky way to subtract the top padding value?
|
||||
const headerHeight = useMemo(() => {
|
||||
if (!enabled) {
|
||||
return 0;
|
||||
}
|
||||
if (!hasWrappedColHeaders) {
|
||||
return defaultHeight - TABLE.CELL_PADDING;
|
||||
}
|
||||
|
||||
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, -1, maxWrapCellOptions);
|
||||
return calcRowHeight(maxLinesText, columnAvailableWidths[maxLinesIdx], defaultHeight) - TABLE.CELL_PADDING;
|
||||
}, [fields, enabled, hasWrappedColHeaders, maxWrapCellOptions, calcRowHeight, columnAvailableWidths, defaultHeight]);
|
||||
return getRowHeight(
|
||||
fields,
|
||||
-1,
|
||||
columnAvailableWidths,
|
||||
TABLE.HEADER_HEIGHT,
|
||||
lineCounters,
|
||||
TABLE.LINE_HEIGHT,
|
||||
TABLE.CELL_PADDING
|
||||
);
|
||||
}, [fields, enabled, columnAvailableWidths, lineCounters]);
|
||||
|
||||
return headerHeight;
|
||||
}
|
||||
@ -455,42 +391,15 @@ export function useRowHeight({
|
||||
hasNestedFrames,
|
||||
defaultHeight,
|
||||
expandedRows,
|
||||
typographyCtx: { calcRowHeight, avgCharWidth },
|
||||
typographyCtx,
|
||||
}: UseRowHeightOptions): number | ((row: TableRow) => number) {
|
||||
const [wrappedColIdxs, hasWrappedCols] = useMemo(() => {
|
||||
let hasWrappedCols = false;
|
||||
return [
|
||||
fields.map((field) => {
|
||||
if (field.type !== FieldType.string) {
|
||||
return false;
|
||||
}
|
||||
const lineCounters = useMemo(() => buildRowLineCounters(fields, typographyCtx), [fields, typographyCtx]);
|
||||
const hasWrappedCols = useMemo(() => lineCounters?.length ?? 0 > 0, [lineCounters]);
|
||||
|
||||
const cellOptions = getCellOptions(field);
|
||||
const wrapText = 'wrapText' in cellOptions && cellOptions.wrapText;
|
||||
const type = cellOptions.type;
|
||||
const result = !!wrapText && type !== TableCellDisplayMode.Image;
|
||||
if (result === true) {
|
||||
hasWrappedCols = true;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
hasWrappedCols,
|
||||
];
|
||||
}, [fields]);
|
||||
|
||||
const colWidths = useMemo(
|
||||
() => columnWidths.map((c) => c - 2 * TABLE.CELL_PADDING - TABLE.BORDER_RIGHT),
|
||||
[columnWidths]
|
||||
);
|
||||
|
||||
const maxWrapCellOptions = useMemo<GetMaxWrapCellOptions>(
|
||||
() => ({
|
||||
colWidths,
|
||||
avgCharWidth,
|
||||
wrappedColIdxs,
|
||||
}),
|
||||
[colWidths, avgCharWidth, wrappedColIdxs]
|
||||
);
|
||||
const colWidths = useMemo(() => {
|
||||
const columnWidthAffordance = 2 * TABLE.CELL_PADDING + TABLE.BORDER_RIGHT;
|
||||
return columnWidths.map((c) => c - columnWidthAffordance);
|
||||
}, [columnWidths]);
|
||||
|
||||
const rowHeight = useMemo(() => {
|
||||
// row height is only complicated when there are nested frames or wrapped columns.
|
||||
@ -498,6 +407,9 @@ export function useRowHeight({
|
||||
return defaultHeight;
|
||||
}
|
||||
|
||||
// this cache should get blown away on resize, data refresh, updated fields, etc.
|
||||
// caching by __index is ok because sorting does not modify the __index.
|
||||
const cache: Array<number | undefined> = Array(fields[0].values.length);
|
||||
return (row: TableRow) => {
|
||||
// nested rows
|
||||
if (row.__depth > 0) {
|
||||
@ -512,23 +424,25 @@ export function useRowHeight({
|
||||
}
|
||||
|
||||
const nestedHeaderHeight = row.data?.meta?.custom?.noHeader ? 0 : defaultHeight;
|
||||
return Math.max(defaultHeight, defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2);
|
||||
return defaultHeight * rowCount + nestedHeaderHeight + TABLE.CELL_PADDING * 2;
|
||||
}
|
||||
|
||||
// regular rows
|
||||
const { text: maxLinesText, idx: maxLinesIdx } = getMaxWrapCell(fields, row.__index, maxWrapCellOptions);
|
||||
return calcRowHeight(maxLinesText, colWidths[maxLinesIdx], defaultHeight);
|
||||
let result = cache[row.__index];
|
||||
if (!result) {
|
||||
result = cache[row.__index] = getRowHeight(
|
||||
fields,
|
||||
row.__index,
|
||||
colWidths,
|
||||
defaultHeight,
|
||||
lineCounters,
|
||||
TABLE.LINE_HEIGHT,
|
||||
TABLE.CELL_PADDING * 2
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}, [
|
||||
calcRowHeight,
|
||||
defaultHeight,
|
||||
expandedRows,
|
||||
fields,
|
||||
hasNestedFrames,
|
||||
hasWrappedCols,
|
||||
maxWrapCellOptions,
|
||||
colWidths,
|
||||
]);
|
||||
}, [hasNestedFrames, hasWrappedCols, defaultHeight, fields, colWidths, lineCounters, expandedRows]);
|
||||
|
||||
return rowHeight;
|
||||
}
|
||||
|
@ -261,3 +261,29 @@ export interface ScrollPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface TypographyCtx {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
font: string;
|
||||
avgCharWidth: number;
|
||||
estimateLines: LineCounter;
|
||||
wrappedCount: LineCounter;
|
||||
}
|
||||
|
||||
export type LineCounter = (value: unknown, width: number) => number;
|
||||
export interface LineCounterEntry {
|
||||
/**
|
||||
* given a values and the available width, returns the line count for that value
|
||||
*/
|
||||
counter: LineCounter;
|
||||
/**
|
||||
* if getting an accurate line count is expensive, you can provide an estimate method
|
||||
* which will be used when looping over the row. the counter method will only be invoked
|
||||
* for the cell which is the maximum line count for the row.
|
||||
*/
|
||||
estimate?: LineCounter;
|
||||
/**
|
||||
* indicates which field indexes of the visible fields this line counter applies to.
|
||||
*/
|
||||
fieldIdxs: number[];
|
||||
}
|
||||
|
@ -16,7 +16,8 @@ import { BarGaugeDisplayMode, TableCellBackgroundDisplayMode, TableCellHeight }
|
||||
|
||||
import { TableCellDisplayMode } from '../types';
|
||||
|
||||
import { TABLE } from './constants';
|
||||
import { COLUMN, TABLE } from './constants';
|
||||
import { LineCounterEntry } from './types';
|
||||
import {
|
||||
extractPixelValue,
|
||||
frameToRecords,
|
||||
@ -31,8 +32,14 @@ import {
|
||||
getJustifyContent,
|
||||
migrateTableDisplayModeToCellOptions,
|
||||
getColumnTypes,
|
||||
getMaxWrapCell,
|
||||
computeColWidths,
|
||||
getRowHeight,
|
||||
buildRowLineCounters,
|
||||
buildHeaderLineCounters,
|
||||
getTextLineEstimator,
|
||||
createTypographyContext,
|
||||
applySort,
|
||||
SINGLE_LINE_ESTIMATE_THRESHOLD,
|
||||
} from './utils';
|
||||
|
||||
describe('TableNG utils', () => {
|
||||
@ -975,117 +982,345 @@ describe('TableNG utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaxWrapCell', () => {
|
||||
it('should return the maximum wrap cell length from field state', () => {
|
||||
const field1: Field = {
|
||||
name: 'field1',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['beep boop', 'foo bar baz', 'lorem ipsum dolor sit amet'],
|
||||
};
|
||||
describe('createTypographyCtx', () => {
|
||||
// we can't test the effectiveness of this typography context in unit tests, only that it
|
||||
// actually executed the JS correctly. If you called `count` with a sensible value and width,
|
||||
// it wouldn't give you a very reasonable answer in Jest's DOM environment for some reason.
|
||||
it('creates the context using uwrap', () => {
|
||||
const ctx = createTypographyContext(14, 'sans-serif', 0.15);
|
||||
expect(ctx).toEqual(
|
||||
expect.objectContaining({
|
||||
font: '14px sans-serif',
|
||||
ctx: expect.any(CanvasRenderingContext2D),
|
||||
wrappedCount: expect.any(Function),
|
||||
estimateLines: expect.any(Function),
|
||||
avgCharWidth: expect.any(Number),
|
||||
})
|
||||
);
|
||||
expect(ctx.wrappedCount('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number));
|
||||
expect(ctx.estimateLines('the quick brown fox jumps over the lazy dog', 100)).toEqual(expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
const field2: Field = {
|
||||
name: 'field2',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['asdfasdf asdfasdf asdfasdf', 'asdf asdf asdf asdf asdf', ''],
|
||||
};
|
||||
describe('getTextLineEstimator', () => {
|
||||
const counter = getTextLineEstimator(10);
|
||||
|
||||
const field3: Field = {
|
||||
name: 'field3',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['foo', 'bar', 'baz'],
|
||||
// No alignmentFactors in state
|
||||
};
|
||||
|
||||
const fields = [field1, field2, field3];
|
||||
|
||||
const result = getMaxWrapCell(fields, 0, {
|
||||
colWidths: [30, 50, 100],
|
||||
avgCharWidth: 5,
|
||||
wrappedColIdxs: [true, true, true],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: 'asdfasdf asdfasdf asdfasdf',
|
||||
idx: 1,
|
||||
numLines: 2.6,
|
||||
});
|
||||
it('returns -1 if there are no strings or dashes within the string', () => {
|
||||
expect(counter('asdfasdfasdfasdfasdfasdfasdfasdfasdfasdfasdf', 5)).toBe(-1);
|
||||
});
|
||||
|
||||
it('should take colWidths into account when calculating max wrap cell', () => {
|
||||
it('calculates an approximate rendered height for the text based on the width and avgCharWidth', () => {
|
||||
expect(counter('asdfas dfasdfasdf asdfasdfasdfa sdfasdfasdfasdf 23', 200)).toBe(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeaderLineCounters', () => {
|
||||
const ctx = {
|
||||
font: '14px sans-serif',
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
count: jest.fn(() => 2),
|
||||
avgCharWidth: 7,
|
||||
wrappedCount: jest.fn(() => 2),
|
||||
estimateLines: jest.fn(() => 2),
|
||||
};
|
||||
|
||||
it('returns an array of line counters for each column', () => {
|
||||
const fields: Field[] = [
|
||||
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { wrapHeaderText: true } } },
|
||||
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
|
||||
];
|
||||
const counters = buildHeaderLineCounters(fields, ctx);
|
||||
expect(counters![0].counter).toEqual(expect.any(Function));
|
||||
expect(counters![0].fieldIdxs).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('does not return the index of columns which are not wrapped', () => {
|
||||
const fields: Field[] = [
|
||||
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
|
||||
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { wrapHeaderText: true } } },
|
||||
];
|
||||
|
||||
const counters = buildHeaderLineCounters(fields, ctx);
|
||||
expect(counters![0].fieldIdxs).toEqual([1]);
|
||||
});
|
||||
|
||||
it('returns undefined if no columns are wrapped', () => {
|
||||
const fields: Field[] = [
|
||||
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
|
||||
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
|
||||
];
|
||||
|
||||
const counters = buildHeaderLineCounters(fields, ctx);
|
||||
expect(counters).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRowLineCounters', () => {
|
||||
const ctx = {
|
||||
font: '14px sans-serif',
|
||||
ctx: {} as CanvasRenderingContext2D,
|
||||
count: jest.fn(() => 2),
|
||||
wrappedCount: jest.fn(() => 2),
|
||||
estimateLines: jest.fn(() => 2),
|
||||
avgCharWidth: 7,
|
||||
};
|
||||
|
||||
it('returns an array of line counters for each column', () => {
|
||||
const fields: Field[] = [
|
||||
{ name: 'Name', type: FieldType.string, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
|
||||
{
|
||||
name: 'field',
|
||||
name: 'Address',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'a bit longer text'],
|
||||
values: [],
|
||||
config: { custom: { cellOptions: { wrapText: true } } },
|
||||
},
|
||||
];
|
||||
const counters = buildRowLineCounters(fields, ctx);
|
||||
expect(counters![0].counter).toEqual(expect.any(Function));
|
||||
expect(counters![0].fieldIdxs).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('does not return the index of columns which are not wrapped', () => {
|
||||
const fields: Field[] = [
|
||||
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
|
||||
{
|
||||
name: 'field',
|
||||
name: 'Address',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'quite a bit longer text'],
|
||||
},
|
||||
{
|
||||
name: 'field',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'less text'],
|
||||
values: [],
|
||||
config: { custom: { cellOptions: { wrapText: true } } },
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate a narrow column width that would cause wrapping
|
||||
const colWidths = [50, 1000, 30]; // 50px width
|
||||
const avgCharWidth = 5; // Assume average character width is 5px
|
||||
|
||||
const result = getMaxWrapCell(fields, 1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
|
||||
|
||||
// With a 50px width and 5px per character, we can fit 10 characters per line
|
||||
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
|
||||
expect(result).toEqual({
|
||||
idx: 0,
|
||||
numLines: 1.7,
|
||||
text: 'a bit longer text',
|
||||
});
|
||||
const counters = buildRowLineCounters(fields, ctx);
|
||||
expect(counters![0].fieldIdxs).toEqual([1]);
|
||||
});
|
||||
|
||||
it('should use the display name if the rowIdx is -1 (which is used to calc header height in wrapped rows)', () => {
|
||||
it('does not enable text counting for non-string fields', () => {
|
||||
const fields: Field[] = [
|
||||
{
|
||||
name: 'Field with a very long name',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'a bit longer text'],
|
||||
},
|
||||
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
|
||||
{ name: 'Age', type: FieldType.number, values: [], config: { custom: { cellOptions: { wrapText: true } } } },
|
||||
];
|
||||
|
||||
const counters = buildRowLineCounters(fields, ctx);
|
||||
// empty array - we had one column that indicated it wraps, but it was numeric, so we just ignore it
|
||||
expect(counters).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an undefined if no columns are wrapped', () => {
|
||||
const fields: Field[] = [
|
||||
{ name: 'Name', type: FieldType.string, values: [], config: { custom: {} } },
|
||||
{ name: 'Age', type: FieldType.number, values: [], config: { custom: {} } },
|
||||
];
|
||||
|
||||
const counters = buildRowLineCounters(fields, ctx);
|
||||
expect(counters).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRowHeight', () => {
|
||||
let fields: Field[];
|
||||
let counters: LineCounterEntry[];
|
||||
|
||||
beforeEach(() => {
|
||||
fields = [
|
||||
{
|
||||
name: 'Name',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'quite a bit longer text'],
|
||||
values: ['foo', 'bar', 'baz', 'longer one here', 'shorter'],
|
||||
config: { custom: { cellOptions: { wrapText: true } } },
|
||||
},
|
||||
{
|
||||
name: 'Another field',
|
||||
type: FieldType.string,
|
||||
config: {},
|
||||
values: ['short', 'less text'],
|
||||
name: 'Age',
|
||||
type: FieldType.number,
|
||||
values: [1, 2, 3, 123456, 789122349932],
|
||||
config: { custom: { cellOptions: { wrapText: true } } },
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate a narrow column width that would cause wrapping
|
||||
const colWidths = [50, 1000, 30]; // 50px width
|
||||
const avgCharWidth = 5; // Assume average character width is 5px
|
||||
|
||||
const result = getMaxWrapCell(fields, -1, { colWidths, avgCharWidth, wrappedColIdxs: [true, true, true] });
|
||||
|
||||
// With a 50px width and 5px per character, we can fit 10 characters per line
|
||||
// "the longest text in this field" has 31 characters, so it should wrap to 4 lines
|
||||
expect(result).toEqual({ idx: 0, numLines: 2.7, text: 'Field with a very long name' });
|
||||
counters = [
|
||||
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
|
||||
{ counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)), fieldIdxs: [1] }, // Mocked to return a line for every 3 digits of a number
|
||||
];
|
||||
});
|
||||
|
||||
it.todo('should ignore columns which are not wrapped');
|
||||
it('should use the default height for single-line rows', () => {
|
||||
// 1 line @ 20px, 10px vertical padding = 30, minimum is 36
|
||||
expect(getRowHeight(fields, 0, [30, 30], 36, counters, 20, 10)).toBe(36);
|
||||
});
|
||||
|
||||
it.todo('should only apply wrapping on idiomatic break characters (space, -, etc)');
|
||||
it('should use the default height for multi-line rows which are shorter than the default height', () => {
|
||||
// 3 lines @ 5px, 5px vertical padding = 20, minimum is 36
|
||||
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 5, 5)).toBe(36);
|
||||
});
|
||||
|
||||
it('should return the row height using line counters for multi-line', () => {
|
||||
// 3 lines @ 20px ('longer', 'one', 'here'), 10px vertical padding
|
||||
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(70);
|
||||
|
||||
// 4 lines @ 15px (789 122 349 932), 15px vertical padding
|
||||
expect(getRowHeight(fields, 4, [30, 30], 36, counters, 15, 15)).toBe(75);
|
||||
});
|
||||
|
||||
it('should take colWidths into account when calculating max wrap cell', () => {
|
||||
getRowHeight(fields, 3, [50, 60], 36, counters, 20, 10);
|
||||
expect(counters[0].counter).toHaveBeenCalledWith('longer one here', 50);
|
||||
expect(counters[1].counter).toHaveBeenCalledWith(123456, 60);
|
||||
});
|
||||
|
||||
// this is used to calc wrapped header height
|
||||
it('should use the display name if the rowIdx is -1', () => {
|
||||
getRowHeight(fields, -1, [50, 60], 36, counters, 20, 10);
|
||||
expect(counters[0].counter).toHaveBeenCalledWith('Name', 50);
|
||||
expect(counters[1].counter).toHaveBeenCalledWith('Age', 60);
|
||||
});
|
||||
|
||||
it('should ignore columns which do not have line counters', () => {
|
||||
const height = getRowHeight(fields, 3, [30, 30], 36, [counters[1]], 20, 10);
|
||||
// 2 lines @ 20px, 10px vertical padding (not 3 lines, since we don't line count Name)
|
||||
expect(height).toBe(50);
|
||||
});
|
||||
|
||||
it('should return the default height if there are no counters to apply', () => {
|
||||
const height = getRowHeight(fields, 3, [30, 30], 36, [], 20, 10);
|
||||
expect(height).toBe(36);
|
||||
});
|
||||
|
||||
describe('estimations vs. precise counts', () => {
|
||||
beforeEach(() => {
|
||||
counters = [
|
||||
{ counter: jest.fn((value, _length: number) => String(value).split(' ').length), fieldIdxs: [0] }, // Mocked to count words as lines
|
||||
{
|
||||
estimate: jest.fn((value) => String(value).length), // Mocked to return a line for every digits of a number
|
||||
counter: jest.fn((value, _length: number) => Math.ceil(String(value).length / 3)),
|
||||
fieldIdxs: [1],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// 2 lines @ 20px (123,456), 10px vertical padding. when we did this before, 'longer one here' would win, making it 70px.
|
||||
// the `estimate` function is picking `123456` as the longer one now (6 lines), then the `counter` function is used
|
||||
// to calculate the height (2 lines). this is a very forced case, but we just want to prove that it actually works.
|
||||
it('uses the estimate value rather than the precise value to select the row height', () => {
|
||||
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
|
||||
});
|
||||
|
||||
it('returns doesnt bother getting the precise count if the estimates are all below the threshold', () => {
|
||||
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
|
||||
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.1);
|
||||
|
||||
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(36);
|
||||
|
||||
// this is what we really care about - we want to save on performance by not calling the counter in this case.
|
||||
expect(counters[1].counter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the precise count if the estimate is above the threshold, even if its below 1', () => {
|
||||
// NOTE: if this fails, just change the test to use a different value besides 0.1
|
||||
expect(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1).toBeLessThan(1);
|
||||
|
||||
jest.mocked(counters[0].counter).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD - 0.3);
|
||||
jest.mocked(counters[1].estimate!).mockReturnValue(SINGLE_LINE_ESTIMATE_THRESHOLD + 0.1);
|
||||
|
||||
expect(getRowHeight(fields, 3, [30, 30], 36, counters, 20, 10)).toBe(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeColWidths', () => {
|
||||
it('returns the configured widths if all columns set them', () => {
|
||||
expect(
|
||||
computeColWidths(
|
||||
[
|
||||
{
|
||||
name: 'A',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: { custom: { width: 100 } },
|
||||
},
|
||||
{
|
||||
name: 'B',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: { custom: { width: 200 } },
|
||||
},
|
||||
],
|
||||
500
|
||||
)
|
||||
).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it('fills the available space if a column has no width set', () => {
|
||||
expect(
|
||||
computeColWidths(
|
||||
[
|
||||
{
|
||||
name: 'A',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: 'B',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: { custom: { width: 200 } },
|
||||
},
|
||||
],
|
||||
500
|
||||
)
|
||||
).toEqual([300, 200]);
|
||||
});
|
||||
|
||||
it('applies minimum width when auto width would dip below it', () => {
|
||||
expect(
|
||||
computeColWidths(
|
||||
[
|
||||
{
|
||||
name: 'A',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: { custom: { minWidth: 100 } },
|
||||
},
|
||||
{
|
||||
name: 'B',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: { custom: { minWidth: 100 } },
|
||||
},
|
||||
],
|
||||
100
|
||||
)
|
||||
).toEqual([100, 100]);
|
||||
});
|
||||
|
||||
it('should use the global column default width when nothing is set', () => {
|
||||
expect(
|
||||
computeColWidths(
|
||||
[
|
||||
{
|
||||
name: 'A',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: 'B',
|
||||
type: FieldType.string,
|
||||
values: [],
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
// we have two columns but have set the table to the width of one default column.
|
||||
COLUMN.DEFAULT_WIDTH
|
||||
)
|
||||
).toEqual([COLUMN.DEFAULT_WIDTH, COLUMN.DEFAULT_WIDTH]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('displayJsonValue', () => {
|
||||
it.todo('should parse and then stringify string values');
|
||||
it.todo('should not throw for non-serializable string values');
|
||||
it.todo('should stringify non-string values');
|
||||
it.todo('should not throw for non-serializable non-string values');
|
||||
});
|
||||
|
||||
describe('applySort', () => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Property } from 'csstype';
|
||||
import { SortColumn } from 'react-data-grid';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { Count, varPreLine } from 'uwrap';
|
||||
|
||||
import {
|
||||
FieldType,
|
||||
@ -25,7 +26,16 @@ import { getTextColorForAlphaBackground } from '../../../utils/colors';
|
||||
import { TableCellOptions } from '../types';
|
||||
|
||||
import { COLUMN, TABLE } from './constants';
|
||||
import { CellColors, TableRow, ColumnTypes, FrameToRowsConverter, Comparator } from './types';
|
||||
import {
|
||||
CellColors,
|
||||
TableRow,
|
||||
ColumnTypes,
|
||||
FrameToRowsConverter,
|
||||
Comparator,
|
||||
TypographyCtx,
|
||||
LineCounter,
|
||||
LineCounterEntry,
|
||||
} from './types';
|
||||
|
||||
/* ---------------------------- Cell calculations --------------------------- */
|
||||
export type CellNumLinesCalculator = (text: string, cellWidth: number) => number;
|
||||
@ -71,58 +81,190 @@ export function shouldTextWrap(field: Field): boolean {
|
||||
return Boolean(cellOptions?.wrapText);
|
||||
}
|
||||
|
||||
// matches characters which CSS
|
||||
const spaceRegex = /[\s-]/;
|
||||
/**
|
||||
* @internal creates a typography context based on a font size and family. used to measure text
|
||||
* and estimate size of text in cells.
|
||||
*/
|
||||
export function createTypographyContext(fontSize: number, fontFamily: string, letterSpacing = 0.15): TypographyCtx {
|
||||
const font = `${fontSize}px ${fontFamily}`;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
export interface GetMaxWrapCellOptions {
|
||||
colWidths: number[];
|
||||
avgCharWidth: number;
|
||||
wrappedColIdxs: boolean[];
|
||||
ctx.letterSpacing = `${letterSpacing}px`;
|
||||
ctx.font = font;
|
||||
const txt =
|
||||
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s.";
|
||||
const txtWidth = ctx.measureText(txt).width;
|
||||
const avgCharWidth = txtWidth / txt.length + letterSpacing;
|
||||
const { count } = varPreLine(ctx);
|
||||
|
||||
return {
|
||||
ctx,
|
||||
font,
|
||||
avgCharWidth,
|
||||
estimateLines: getTextLineEstimator(avgCharWidth),
|
||||
wrappedCount: wrapUwrapCount(count),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* loop through the fields and their values, determine which cell is going to determine the
|
||||
* height of the row based on its content and width, and then return the text, index, and number of lines for that cell.
|
||||
*/
|
||||
export function getMaxWrapCell(
|
||||
export function wrapUwrapCount(count: Count): LineCounter {
|
||||
return (value, width) => {
|
||||
if (value == null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return count(String(value), width);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal returns a line counter which guesstimates a number of lines in a text cell based on the typography context's avgCharWidth.
|
||||
*/
|
||||
export function getTextLineEstimator(avgCharWidth: number): LineCounter {
|
||||
return (value, width) => {
|
||||
if (!value) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// we don't have string breaking enabled in the table,
|
||||
// so an unbroken string is by definition a single line.
|
||||
const strValue = String(value);
|
||||
if (!spaceRegex.test(strValue)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const charsPerLine = width / avgCharWidth;
|
||||
return strValue.length / charsPerLine;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal return a text line counter for every field which has wrapHeaderText enabled.
|
||||
*/
|
||||
export function buildHeaderLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
|
||||
const wrappedColIdxs = fields.reduce((acc: number[], field, idx) => {
|
||||
if (field.config?.custom?.wrapHeaderText) {
|
||||
acc.push(idx);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (wrappedColIdxs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// don't bother with estimating the line counts for the headers, because it's punishing
|
||||
// when we get it wrong and there won't be that many compared to how many rows a table might contain.
|
||||
return [{ counter: typographyCtx.wrappedCount, fieldIdxs: wrappedColIdxs }];
|
||||
}
|
||||
|
||||
const spaceRegex = /[\s-]/;
|
||||
|
||||
/**
|
||||
* @internal return a text line counter for every field which has wrapHeaderText enabled. we do this once as we're rendering
|
||||
* the table, and then getRowHeight uses the output of this to caluclate the height of each row.
|
||||
*/
|
||||
export function buildRowLineCounters(fields: Field[], typographyCtx: TypographyCtx): LineCounterEntry[] | undefined {
|
||||
const result: Record<string, LineCounterEntry> = {};
|
||||
let wrappedFields = 0;
|
||||
|
||||
for (let fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) {
|
||||
const field = fields[fieldIdx];
|
||||
if (shouldTextWrap(field)) {
|
||||
wrappedFields++;
|
||||
// TODO: Pills, DataLinks, and JSON will have custom line counters here.
|
||||
|
||||
// for string fields, we really want to find the longest field ahead of time to reduce the number of calls to `count`.
|
||||
// calling `count` is going to get a perfectly accurate line count, but it is expensive, so we'd rather estimate the line
|
||||
// count and call the counter only for the field which will take up the most space based on its
|
||||
if (field.type === FieldType.string) {
|
||||
result.textCounter = result.textCounter ?? {
|
||||
counter: typographyCtx.wrappedCount,
|
||||
estimate: typographyCtx.estimateLines,
|
||||
fieldIdxs: [],
|
||||
};
|
||||
result.textCounter.fieldIdxs.push(fieldIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wrappedFields === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.values(result);
|
||||
}
|
||||
|
||||
// in some cases, the estimator might return a value that is less than 1, but when measured by the counter, it actually
|
||||
// realizes that it's a multi-line cell. to avoid this, we want to give a little buffer away from 1 before we fully trust
|
||||
// the estimator to have told us that a cell is single-line.
|
||||
export const SINGLE_LINE_ESTIMATE_THRESHOLD = 0.85;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
* loop through the fields and their values, determine which cell is going to determine the height of the row based
|
||||
* on its content and width, and return the height in pixels of that row, with vertial padding applied.
|
||||
*/
|
||||
export function getRowHeight(
|
||||
fields: Field[],
|
||||
rowIdx: number,
|
||||
{ colWidths, avgCharWidth, wrappedColIdxs }: GetMaxWrapCellOptions
|
||||
): {
|
||||
text: string;
|
||||
idx: number;
|
||||
numLines: number;
|
||||
} {
|
||||
let maxLines = 1;
|
||||
let maxLinesIdx = -1;
|
||||
let maxLinesText = '';
|
||||
columnWidths: number[],
|
||||
defaultHeight: number,
|
||||
lineCounters?: LineCounterEntry[],
|
||||
lineHeight = TABLE.LINE_HEIGHT,
|
||||
verticalPadding = 0
|
||||
): number {
|
||||
if (!lineCounters?.length) {
|
||||
return defaultHeight;
|
||||
}
|
||||
|
||||
// TODO: consider changing how we store this, using a record by column key instead of an array
|
||||
for (let i = 0; i < colWidths.length; i++) {
|
||||
if (wrappedColIdxs[i]) {
|
||||
const field = fields[i];
|
||||
let maxLines = -1;
|
||||
let maxValue = '';
|
||||
let maxWidth = 0;
|
||||
let preciseCounter: LineCounter | undefined;
|
||||
|
||||
for (const { estimate, counter, fieldIdxs } of lineCounters) {
|
||||
// for some of the line counters, getting the precise count of the lines is expensive. those line counters
|
||||
// set both an "estimate" and a "counter" function. if the cell we find to be the max was estimated, we will
|
||||
// get the "true" value right before calculating the row height by hanging onto a reference to the counter fn.
|
||||
const count = estimate ?? counter;
|
||||
const isEstimating = estimate !== undefined;
|
||||
|
||||
for (const fieldIdx of fieldIdxs) {
|
||||
const field = fields[fieldIdx];
|
||||
// special case: for the header, provide `-1` as the row index.
|
||||
const cellTextRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
|
||||
|
||||
if (cellTextRaw != null) {
|
||||
const cellText = String(cellTextRaw);
|
||||
|
||||
if (spaceRegex.test(cellText)) {
|
||||
const charsPerLine = colWidths[i] / avgCharWidth;
|
||||
const approxLines = cellText.length / charsPerLine;
|
||||
|
||||
if (approxLines > maxLines) {
|
||||
maxLines = approxLines;
|
||||
maxLinesIdx = i;
|
||||
maxLinesText = cellText;
|
||||
}
|
||||
const cellValueRaw = rowIdx === -1 ? getDisplayName(field) : field.values[rowIdx];
|
||||
if (cellValueRaw != null) {
|
||||
const colWidth = columnWidths[fieldIdx];
|
||||
const approxLines = count(cellValueRaw, colWidth);
|
||||
if (approxLines > maxLines) {
|
||||
maxLines = approxLines;
|
||||
maxValue = cellValueRaw;
|
||||
maxWidth = colWidth;
|
||||
preciseCounter = isEstimating ? counter : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: maxLinesText, idx: maxLinesIdx, numLines: maxLines };
|
||||
// if the value is -1 or the estimate for the max cell was less than the SINGLE_LINE_ESTIMATE_THRESHOLD, we trust
|
||||
// that the estimator correctly identified that no text wrapping is needed for this row, skipping the preciseCounter.
|
||||
if (maxLines < SINGLE_LINE_ESTIMATE_THRESHOLD) {
|
||||
return defaultHeight;
|
||||
}
|
||||
|
||||
// if we finished this row height loop with an estimate, we need to call
|
||||
// the `preciseCounter` method to get the exact line count.
|
||||
if (preciseCounter !== undefined) {
|
||||
maxLines = preciseCounter(maxValue, maxWidth);
|
||||
}
|
||||
|
||||
// we want a round number of lines for rendering
|
||||
const totalHeight = Math.ceil(maxLines) * lineHeight + verticalPadding;
|
||||
return Math.max(totalHeight, defaultHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
|
10
yarn.lock
10
yarn.lock
@ -3869,7 +3869,7 @@ __metadata:
|
||||
typescript: "npm:5.8.3"
|
||||
uplot: "npm:1.6.32"
|
||||
uuid: "npm:11.1.0"
|
||||
uwrap: "npm:0.1.1"
|
||||
uwrap: "npm:0.1.2"
|
||||
webpack: "npm:5.97.1"
|
||||
peerDependencies:
|
||||
react: ^18.0.0
|
||||
@ -31787,10 +31787,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uwrap@npm:0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "uwrap@npm:0.1.1"
|
||||
checksum: 10/d5d02cb2f0e7fd997862913458d67e0c7fa9fd5bc1025baca9e183ac87046be9148942e59440fef8d01a34d5674c0395bb46b13e00359602ea3155b305466090
|
||||
"uwrap@npm:0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "uwrap@npm:0.1.2"
|
||||
checksum: 10/621d9d148d903410ef555739baca1ba84500d59a5028611bc2fca53313ffd6c9b870e25dc5cad73b41f18fe72879a3d94a6b8ebc5e141c84a94188ee1c483492
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
Reference in New Issue
Block a user