Skip to content

Commit

Permalink
Merge pull request #373 from ajthinking/json-option-in-table
Browse files Browse the repository at this point in the history
Json option in table
  • Loading branch information
stone-lyl authored Jan 27, 2025
2 parents dc4d583 + 0974303 commit eb8440c
Show file tree
Hide file tree
Showing 13 changed files with 305 additions and 136 deletions.
5 changes: 5 additions & 0 deletions packages/core/src/computers/Table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export const Table: Computer = {
help: 'If set, the specified paths will be dropped. Use comma separation',
value: '',
}),
str({
name: 'destructObjects',
help: 'If set, objects will be destructured',
value: 'true',
})
],

async* run({ input, hooks, params: rawParams, node, storage }) {
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/utils/isJson.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export const isJson = (str: string) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
};

export const isJsonArray = (str: string) => {
try {
const result = JSON.parse(str);
if (!Array.isArray(result)) {
return false;
}

return true;
} catch (e) {
return false;
}
};

export const isJsonObject = (str: string) => {
try {
const result = JSON.parse(str);
if (typeof result !== 'object') {
return false;
}

return true;
} catch (e) {
return false;
}
};
105 changes: 105 additions & 0 deletions packages/ui/src/components/Node/table/CellsMatrix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Cell, RowModel } from '@tanstack/react-table';
import { VirtualItem } from '@tanstack/react-virtual';

interface VisibleCell {
rowIndex: number;
cell: Cell<Record<string, unknown>, unknown>;
offsetTop: number;
}

export const CELL_WIDTH = 75;
export const CELL_MAX_WIDTH = 150;
export const CELL_MIN_WIDTH = 50;
export const DEFAULT_CHAR_WIDTH = 8;
export const DEFAULT_PADDING = 8;

export interface ColumnWidthOptions {
charWidth?: number;
maxWidth?: number;
minWidth?: number;
padding?: number;
}

export class CellsMatrix {
private readonly getRowModel: () => RowModel<Record<string, unknown>>;
private readonly virtualRows: VirtualItem[];
private readonly virtualColumns: VirtualItem[];
private readonly cellsMatrix: VisibleCell[][];
private columnWidthsMap: Map<number, number> = new Map();

constructor({
virtualRows,
virtualColumns,
getRowModel
}: {
virtualRows: VirtualItem[];
virtualColumns: VirtualItem[];
getRowModel: () => RowModel<Record<string, unknown>>
}) {
this.virtualRows = virtualRows;
this.virtualColumns = virtualColumns;
this.getRowModel = getRowModel;
this.cellsMatrix = this.initializeCellsMatrix();
}

// Initialize a 2D array representing columns and their cells
private initializeCellsMatrix(): VisibleCell[][] {
const matrix: VisibleCell[][] = Array(this.virtualColumns.length)
.fill(null)
.map(() => []);

this.virtualRows.forEach(virtualRow => {
const row = this.getRowModel().rows[virtualRow.index];
if (!row) return;

this.virtualColumns.forEach((virtualCol, colIndex) => {
const cell = row.getVisibleCells()[virtualCol.index];
if (!cell) return;

matrix[colIndex].push({
rowIndex: virtualRow.index,
cell,
offsetTop: virtualRow.start
});
});
});

return matrix;
}

public getColumnCells(columnIndex: number): VisibleCell[] {
return this.cellsMatrix[columnIndex];
}

// Calculate and cache the optimal width for a column
public calculateColumnWidth(colIndex: number, options: ColumnWidthOptions = {}): number {
const {
charWidth = DEFAULT_CHAR_WIDTH,
maxWidth = CELL_MAX_WIDTH,
minWidth = CELL_MIN_WIDTH,
padding = DEFAULT_PADDING
} = options;

if (this.columnWidthsMap.has(colIndex)) {
return this.columnWidthsMap.get(colIndex)!;
}

const columnCells = this.getColumnCells(colIndex);
const headerText = columnCells[0]?.cell.column.id || '';

const maxLength = columnCells.reduce((max, { cell }) => {
const contentLength = String(cell.getValue() ?? '').length;
return Math.max(max, contentLength);
}, headerText.length);

// Calculate final width: characters * charWidth + padding
const finalWidth = Math.max(
minWidth,
Math.min(maxWidth, maxLength * charWidth + padding)
);

this.columnWidthsMap.set(colIndex, finalWidth);

return finalWidth;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ItemValue, ItemWithParams } from '@data-story/core';
import { DataStoryNodeData } from '../ReactFlowNode';

export function getFormatterOnlyAndDropParam(items: ItemValue[], data: DataStoryNodeData):
{only: string[], drop: string[], destructObjects: boolean} {
const paramEvaluator = new ItemWithParams(items, data.params, []);
let only: string[] = [], drop: string[] = [];
let destructObjects = true;
try {
only = paramEvaluator.params?.only as string[] ?? [];
drop = paramEvaluator.params?.drop as string[] ?? [];
destructObjects = paramEvaluator.params?.destructObjects === 'false' ? false : true;
} catch(e) {
}
return { only, drop, destructObjects };
}
12 changes: 6 additions & 6 deletions packages/ui/src/components/Node/table/ItemCollection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { multiline } from '@data-story/core';

describe('toTable', () => {
it('should correctly extract headers and rows from normal data', () => {
const { headers, rows } = new ItemCollection([normal]).toTable();
const { headers, rows } = new ItemCollection([normal]).toTable({ only: [], drop: [], destructObjects: true });
expect(headers).toEqual(['property_a', 'property_b', 'property_c', 'property_d', 'property_e', 'property_f', 'property_g', 'property_h', 'property_i']);
expect(rows).toEqual([['10000', '20000', '30000', '40000', '50000', '60000', '70000', '80000', '90000']]);
});
Expand All @@ -23,7 +23,7 @@ describe('toTable', () => {
}
];

const { headers, rows } = new ItemCollection(mockData).toTable();
const { headers, rows } = new ItemCollection(mockData).toTable({ only: [], drop: [], destructObjects: true });
expect(headers).toEqual(['foo', 'baz']);
expect(rows).toEqual([['bar', '[{"foo":"bar","baz":"qux"}]']]);
});
Expand All @@ -36,14 +36,14 @@ describe('toTable', () => {
['bar1', 'bar2', undefined, 'bar1', 'bar2', 'bar1', 'bar2', 'bar3'],
];

const { headers, rows } = new ItemCollection(threeTierNested).toTable();
const { headers, rows } = new ItemCollection(threeTierNested).toTable({ only: [], drop: [], destructObjects: true });

expect(headers).toEqual(expectedHeaders);
expect(rows).toEqual(expectedContent);
});

it('should correctly extract headers and rows from nested JSON data', () => {
const { headers, rows } = new ItemCollection([nested]).toTable();
const { headers, rows } = new ItemCollection([nested]).toTable({ only: [], drop: [], destructObjects: true });

expect(headers).toEqual(
[
Expand Down Expand Up @@ -87,7 +87,7 @@ describe('toTable', () => {
});

it('should handle empty array input', () => {
const { headers, rows } = new ItemCollection([]).toTable();
const { headers, rows } = new ItemCollection([]).toTable({ only: [], drop: [], destructObjects: true });
expect(headers).toEqual([]);
expect(rows).toEqual([]);
});
Expand All @@ -107,7 +107,7 @@ describe('toTable', () => {
},
];

const { headers, rows } = new ItemCollection(mockData).toTable();
const { headers, rows } = new ItemCollection(mockData).toTable({ only: [], drop: [], destructObjects: true });
expect(headers).toEqual(['name.first', 'name.last', 'age', 'name']);
expect(rows).toEqual([
['John', 'Doe', '21', undefined],
Expand Down
30 changes: 21 additions & 9 deletions packages/ui/src/components/Node/table/ItemCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,40 @@ import { ItemValue, get } from '@data-story/core'

type JSONValue = string | number | boolean | {[key: string]: JSONValue} | JSONValue[];

interface TableOptions {
only?: string[];
drop?: string[];
destructObjects?: boolean;
}

export class ItemCollection {
constructor(public items: ItemValue[]) {
}

toTable(only: string[] = [], drop: string[] = []) {
toTable(options: TableOptions = {}) {
const { only = [], drop = [], destructObjects = true } = options;
const headers: Set<string> = new Set();
const rows: (string | undefined)[][] = [];

/**
* @description recursively build headers
*/
const buildHeaders = (entry: {[key: string]: JSONValue}, prefix: string = '') => {
Object.entries(entry).forEach(([key, value]) => {
const newKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
buildHeaders(value as {[key: string]: JSONValue}, newKey);
const buildHeaders = (object: {[key: string]: JSONValue}, prefix: string = '') => {
Object.entries(object).forEach(([property, value]) => {
const fullPath = prefix ? `${prefix}.${property}` : property;

if (isNestedObject(value) && destructObjects) {
buildHeaders(value, fullPath);
} else {
headers.add(newKey);
headers.add(fullPath);
}
});
};

const isNestedObject = (value: any): value is {[key: string]: JSONValue} => {
return typeof value === 'object' && value !== null && !Array.isArray(value);
};

/**
* @description recursive data to build headers
*/
Expand All @@ -47,7 +59,7 @@ export class ItemCollection {
* @description get value by header's path
*/
const getValueByPath = (object: ItemValue, path: string): string | undefined => {
let rawValue: any = get(object, path);
let rawValue: any = destructObjects ? get(object, path) : get(object, path.split('.')[0]);

const currentType = typeof rawValue;

Expand All @@ -56,7 +68,7 @@ export class ItemCollection {
}

if (currentType === 'object' && rawValue !== null) {
return undefined;
return destructObjects ? undefined : JSON.stringify(rawValue);
}

return typeof rawValue === 'string'
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/components/Node/table/LoadingComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function LoadingComponent() {
className="whitespace-nowrap bg-gray-200 text-left px-1 border-r-0.5 last:border-r-0 border-gray-300 sticky top-0 z-10">
Awaiting data
</div>
<div className="text-center bg-gray-100 hover:bg-gray-200">
<div className="text-left bg-gray-100 hover:bg-gray-200">
Load initial data...
</div>
</div>;
Expand Down
17 changes: 9 additions & 8 deletions packages/ui/src/components/Node/table/MemoizedTableBody.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import React, { memo } from 'react';
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import { flexRender, RowModel } from '@tanstack/react-table';
import { calculateColumnWidth, FIXED_HEIGHT } from './TableCell';
import { FIXED_HEIGHT } from './TableCell';
import { ColumnWidthOptions } from './CellsMatrix';

export const MemoizedTableBody = memo(({
virtualRows,
getRowModel,
virtualColumns,
rowVirtualizer
rowVirtualizer,
calculateColumnWidth,
}: {
virtualRows: VirtualItem[];
virtualColumns: VirtualItem[];
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
getRowModel: () => RowModel<Record<string, unknown>>
getRowModel: () => RowModel<Record<string, unknown>>;
calculateColumnWidth: (colIndex: number,options?: ColumnWidthOptions) => number;
}) => {
return (
<tbody
Expand All @@ -23,15 +26,13 @@ export const MemoizedTableBody = memo(({
}}>
{virtualRows.map((virtualRow, rowindex) => {
const row = getRowModel().rows[virtualRow.index];
// console.log(row.getVisibleCells(), 'row');
return (
<tr
data-cy={'data-story-table-row'}
className="odd:bg-gray-50 w-full text-xs"
key={row.id}
style={{
display: 'flex',
width: '100%',
height: `${FIXED_HEIGHT}px`,
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`,
Expand All @@ -44,13 +45,13 @@ export const MemoizedTableBody = memo(({
width: 'calc(var(--virtual-padding-left) * 1px)',
}}
/>
{virtualColumns.map((virtualColumn) => {
{virtualColumns.map((virtualColumn, index) => {
const cell = row.getVisibleCells()[virtualColumn.index];
const columnWidth = calculateColumnWidth(cell);
const columnWidth = calculateColumnWidth(index);
return (
<td
key={cell.id}
className="whitespace-nowrap text-left"
className="max-w-[256px] whitespace-nowrap text-left border-r-0.5 last:border-r-0 border-gray-300"
style={{
display: 'flex',
position: 'relative',
Expand Down
12 changes: 7 additions & 5 deletions packages/ui/src/components/Node/table/MemoizedTableHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import React, { memo } from 'react';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { VirtualItem } from '@tanstack/react-virtual';
import { calculateColumnWidth } from './TableCell';
import { ColumnWidthOptions } from './CellsMatrix';

export const MemoizedTableHeader = memo(({
headerGroups,
virtualColumns
virtualColumns,
calculateColumnWidth,
}: {
headerGroups: HeaderGroup<Record<string, unknown>>[];
virtualColumns: VirtualItem[];
calculateColumnWidth: (colIndex: number,options?: ColumnWidthOptions) => number;
}) => {
return (
<thead
Expand All @@ -31,9 +33,9 @@ export const MemoizedTableHeader = memo(({
}}
/>
{
virtualColumns.map((virtualColumn) => {
virtualColumns.map((virtualColumn, index) => {
const headerColumn = headerGroup.headers[virtualColumn.index];
const columnWidth = calculateColumnWidth(headerColumn);
const columnWidth = calculateColumnWidth(index);

return (
<th
Expand All @@ -44,7 +46,7 @@ export const MemoizedTableHeader = memo(({
position: 'relative',
width: `${columnWidth}px`,
}}
className="whitespace-nowrap bg-gray-200 text-left border-r-0.5 last:border-r-0 border-gray-300"
className="max-w-[256px] whitespace-nowrap bg-gray-200 text-left border-r-0.5 last:border-r-0 border-gray-300"
>
{flexRender(headerColumn.column.columnDef.header, headerColumn.getContext())}
</th>
Expand Down
Loading

0 comments on commit eb8440c

Please sign in to comment.