CSV viewer plugin enhancements (#33)

* init
* render table
* add packages
* filtering and sorting
* init pagination
* partially working min-max
* fix broken style
* styles fixes
* init
* sticky header
* resizable cols
* scrollable tbody instead
* remove unused deps
* add file size limit
* style fixes
* scrollable x-axis
* border fixes
* more style fixes
* handle csv without headers
* fix headers rendering
* revert changes to model
* revert changes to code plugin
* ellipsis mixin
* fix height calc problem
* minor fix
* minor fix
* minor fix
* minor fix
* minor fix
* reset model and code plugin changes
* more fixes
* fix wrong theme import path
This commit is contained in:
Red J Adaya 2023-10-13 13:55:11 +08:00 committed by GitHub
parent 17a85734c8
commit 1fafc53605
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 157 additions and 361 deletions

View File

@ -1,126 +1,92 @@
@import "../../app/common/themes/themes.less";
.csv-renderer {
.ellipsis() {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
overflow-x: auto;
overflow-y: hidden;
.cursor-pointer {
cursor: pointer;
}
.select-none {
user-select: none;
}
/* Flexbox utilities */
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: 8px;
}
/* Button styles */
button.border {
border: 1px solid #ccc;
}
button.rounded {
border-radius: 4px;
}
button.p-1 {
padding: 4px;
}
/* Disabling styles for buttons */
button[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
/* Input styles */
input.border,
select.border {
border: 1px solid #ccc;
}
input.rounded,
select.rounded {
border-radius: 4px;
}
input.p-1,
select.p-1 {
padding: 4px;
}
input.w-16 {
width: 64px;
}
/* Text styles */
strong {
font-weight: bold;
}
.global-search-render {
margin-bottom: 10px;
}
table {
border: 1px solid rgb(146, 146, 146);
margin-bottom: 10px;
border-collapse: collapse;
overflow-x: auto;
border: 1px solid @scrollbar-thumb;
tbody {
border-bottom: 1px solid rgb(146, 146, 146);
}
thead {
position:relative;
display: block;
width: 100%;
overflow-y: scroll;
th {
color: white;
border-bottom: 1px solid rgb(146, 146, 146);
border-right: 1px solid rgb(146, 146, 146);
padding: 2px 10px 4px 10px;
tr {
border-bottom: 1px solid @scrollbar-thumb;
.min-max-renderer {
min-width: 200px;
display: flex;
th {
color:@term-white;
border: 1px solid @scrollbar-thumb;
border-bottom: none;
padding: 2px 10px 4px 10px;
flex-basis:100%;
flex-grow:2;
display: block;
text-align:left;
position: relative;
.search-renderer {
flex: 1;
.inner {
text-align:left;
padding-right: 15px;
position: relative;
.ellipsis();
input {
width: 99%;
.sort-icon {
filter: invert(100%);
position: absolute;
right: 0px;
top: 2px;
width: 9px;
}
}
}
.search-renderer:first-child {
margin-right: 3px;
}
}
}
tbody {
display: block;
position:relative;
overflow-y: scroll;
overscroll-behavior: contain;
}
tr {
td {
border-right: 1px solid rgb(146, 146, 146);
padding: 2px 10px;
}
td:last-child {
border-right: none;
}
}
}
width: 100%;
display:flex;
.pagination-renderer {
.page-label {
div {
padding-right: 6px;
}
strong {
color: white;
td {
border-right: 1px solid @scrollbar-thumb;
border-left: 1px solid @scrollbar-thumb;
padding: 2px 10px;
flex-basis:100%;
flex-grow:2;
display: block;
text-align:left;
.ellipsis();
}
}
}
}
}

View File

@ -6,36 +6,20 @@ import {
createColumnHelper,
flexRender,
useReactTable,
ColumnFiltersState,
getCoreRowModel,
getFilteredRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFacetedMinMaxValues,
getPaginationRowModel,
sortingFns,
getSortedRowModel,
FilterFn,
} from '@tanstack/react-table'
import {
RankingInfo,
rankItem,
compareItems,
} from '@tanstack/match-sorter-utils'
import Filter from "./filter";
import DebouncedInput from "./search";
import Pagination from "./pagination";
import SortUpIcon from './img/sort-up-solid.svg';
import SortDownIcon from './img/sort-down-solid.svg';
import "./csv.less";
declare module '@tanstack/table-core' {
interface FilterFns {
fuzzy: FilterFn<unknown>
}
interface FilterMeta {
itemRank: RankingInfo
}
}
const MAX_DATA_SIZE = 10 * 1024 * 1024 // 10MB in bytes
type CSVRow = {
[key: string]: string | number;
@ -52,27 +36,6 @@ const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Return if the item should be filtered in/out
return itemRank.passed
}
const fuzzySort: SortingFn<any> = (rowA, rowB, columnId) => {
let dir = 0
// Only sort by rank if the column has ranking information
if (rowA.columnFiltersMeta[columnId]) {
dir = compareItems(
rowA.columnFiltersMeta[columnId]?.itemRank!,
rowB.columnFiltersMeta[columnId]?.itemRank!
)
}
// Provide an alphanumeric fallback for when the item ranks are equal
return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir
}
interface DataColumn {
Header: string;
accessor: string;
}
interface Props {
@ -97,22 +60,24 @@ interface State {
message: { status: string; text: string } | null;
isPreviewerAvailable: boolean;
showReadonly: boolean;
totalHeight: number;
}
const columnHelper = createColumnHelper<any>();
const CSVRenderer: FC<Props> = (props: Props) => {
const csvCacheRef = useRef(new Map<string, string>());
const rowRef = useRef<(HTMLTableRowElement | null)[]>([]);
const headerRef = useRef<HTMLTableRowElement | null>(null);
const [state, setState] = useState<State>({
content: null,
message: null,
isPreviewerAvailable: false,
showReadonly: true,
totalHeight: 0,
});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [globalFilter, setGlobalFilter] = React.useState('')
const [globalFilter, setGlobalFilter] = useState('')
const [isFileTooLarge, setIsFileTooLarge] = useState<boolean>(false);
const filePath = props.lineState["prompt:file"];
const { screenId, lineId } = props.context;
@ -121,9 +86,29 @@ const CSVRenderer: FC<Props> = (props: Props) => {
// Parse the CSV data
const parsedData = useMemo<CSVRow[]>(() => {
if (!state.content) return [];
const results = Papa.parse(state.content, { header: true });
// Trim the content and then check for headers based on the first row's content.
const trimmedContent = state.content.trim();
const firstRow = trimmedContent.split('\n')[0];
// This checks if the first row starts with a letter or a quote
const hasHeaders = !!firstRow.match(/^[a-zA-Z"]/);
const results = Papa.parse(trimmedContent, { header: hasHeaders });
// Check for non-header CSVs
if (!hasHeaders && Array.isArray(results.data) && Array.isArray(results.data[0])) {
const dataArray = results.data as string[][]; // Asserting the type
const headers = Array.from({ length: dataArray[0].length }, (_, i) => `Column ${i + 1}`);
results.data = dataArray.map(row => {
const newRow: CSVRow = {};
row.forEach((value, index) => {
newRow[headers[index]] = value;
});
return newRow;
});
}
return results.data.map(row => {
return Object.fromEntries(
Object.entries(row as CSVRow).map(([key, value]) => {
@ -137,7 +122,7 @@ const CSVRenderer: FC<Props> = (props: Props) => {
})
) as CSVRow;
});
}, [state.content]);
}, [state.content]);
// Column Definitions
const columns = useMemo(() => {
@ -158,12 +143,30 @@ const CSVRenderer: FC<Props> = (props: Props) => {
if (content) {
setState((prevState) => ({ ...prevState, content }));
} else {
// Check if the file size exceeds 10MB
if (props.data.size > MAX_DATA_SIZE) { // 10MB in bytes
setIsFileTooLarge(true);
return;
}
props.data.text().then((content: string) => {
setState((prevState) => ({ ...prevState, content }));
csvCacheRef.current.set(cacheKey, content);
});
}
}, []);
}, []);
// Effect to compute height after rendering
useEffect(() => {
if (headerRef.current && rowRef.current && rowRef.current[0]) {
const rowHeight = rowRef.current[0]?.offsetHeight ?? 0; // Using optional chaining
const totalHeight = rowHeight * parsedData.length;
const th = Math.min(totalHeight, props.opts.maxSize.height);
setState((prevState) => ({ ...prevState, totalHeight: th }));
}
}, [parsedData, props.opts]);
const getMessage = () => (
<div style={{ position: "absolute", bottom: "-3px", left: "14px" }}>
@ -183,66 +186,51 @@ const CSVRenderer: FC<Props> = (props: Props) => {
const { content, message } = state;
const table = useReactTable({
manualPagination: true,
data: parsedData,
columns,
filterFns: {
fuzzy: fuzzyFilter,
},
state: {
columnFilters,
globalFilter,
},
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: fuzzyFilter,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
});
if (isFileTooLarge) {
return (
<div className="csv-renderer" style={{ fontSize: GlobalModel.termFontSize.get() }}>
<div className="load-error-text">The file size exceeds 10MB and cannot be displayed.</div>
</div>
);
}
if (content == null) return <div className="csv-renderer" style={{ height: props.savedHeight }} />;
if (exitcode === 1)
return (
<div
className="csv-renderer"
style={{
fontSize: GlobalModel.termFontSize.get(),
color: "white",
}}
>
{content}
</div>
);
return (
<div className="csv-renderer">
<div className="global-search-render">
<DebouncedInput
value={globalFilter ?? ''}
onChange={value => setGlobalFilter(String(value))}
className="global-search"
placeholder="Search all columns..."
/>
</div>
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder
? null
: (
<>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id} ref={headerRef}>
{headerGroup.headers.map(header => (
<th
key={header.id}
colSpan={header.colSpan}
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: (
<div
{...{
className: header.column.getCanSort()
? 'cursor-pointer select-none'
? 'inner cursor-pointer select-none'
: '',
onClick: header.column.getToggleSortingHandler(),
}}
@ -251,36 +239,29 @@ const CSVRenderer: FC<Props> = (props: Props) => {
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
{
header.column.getIsSorted() === 'asc' ? <img src={SortUpIcon} className="sort-icon sort-up-icon" alt="Ascending" /> :
header.column.getIsSorted() === 'desc' ? <img src={SortDownIcon} className="sort-icon sort-down-icon" alt="Descending" /> : null
}
</div>
{header.column.getCanFilter() ? (
<div>
<Filter column={header.column} table={table} />
</div>
) : null}
</>
)}
</th>
))}
</tr>
))}
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
<tbody style={{"height": `${state.totalHeight}px`}}>
{table.getRowModel().rows.map((row, index) => (
<tr key={row.id} ref={el => rowRef.current[index] = el}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<Pagination table={table} />
{message && getMessage()}
</div>
);

View File

@ -1,78 +0,0 @@
import React, { FC, useMemo } from "react";
import DebouncedInput from "./search";
import {
Column,
Table,
} from '@tanstack/react-table';
import Split from "react-split-it";
const Filter: FC<{
column: Column<any, unknown>;
table: Table<any>;
}> = ({ column, table }) => {
const firstValue = table
.getPreFilteredRowModel()
.flatRows[0]?.getValue(column.id);
const columnFilterValue = column.getFilterValue();
const sortedUniqueValues = useMemo(
() =>
typeof firstValue === 'number'
? []
: Array.from(column.getFacetedUniqueValues().keys()).sort(),
[column.getFacetedUniqueValues()]
);
return typeof firstValue === 'number' ? (
<div className="min-max-renderer">
<DebouncedInput
type="number"
min={Number(column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(column.getFacetedMinMaxValues()?.[1] ?? '')}
value={(columnFilterValue as [number, number])?.[0] ?? ''}
onChange={value =>
column.setFilterValue((old: [number, number]) => [value, old?.[1]])
}
placeholder={`Min ${
column.getFacetedMinMaxValues()?.[0]
? `(${column.getFacetedMinMaxValues()?.[0]})`
: ''
}`}
className="w-24 border shadow rounded"
/>
<DebouncedInput
type="number"
min={Number(column.getFacetedMinMaxValues()?.[0] ?? '')}
max={Number(column.getFacetedMinMaxValues()?.[1] ?? '')}
value={(columnFilterValue as [number, number])?.[1] ?? ''}
onChange={value =>
column.setFilterValue((old: [number, number]) => [old?.[0], value])
}
placeholder={`Max ${
column.getFacetedMinMaxValues()?.[1]
? `(${column.getFacetedMinMaxValues()?.[1]})`
: ''
}`}
className="w-24 border shadow rounded"
/>
</div>
) : (
<div className="search-renderer">
<datalist id={column.id + 'list'}>
{sortedUniqueValues.slice(0, 5000).map((value: any) => (
<option value={value} key={value} />
))}
</datalist>
<DebouncedInput
type="text"
value={(columnFilterValue ?? '') as string}
onChange={value => column.setFilterValue(value)}
placeholder={`Search... (${column.getFacetedUniqueValues().size})`}
list={column.id + 'list'}
/>
</div>
);
};
export default Filter

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M182.6 470.6c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-9.2-9.2-11.9-22.9-6.9-34.9s16.6-19.8 29.6-19.8H288c12.9 0 24.6 7.8 29.6 19.8s2.2 25.7-6.9 34.9l-128 128z"/></svg>

After

Width:  |  Height:  |  Size: 403 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M137.4 41.4c12.5-12.5 32.8-12.5 45.3 0l128 128c9.2 9.2 11.9 22.9 6.9 34.9s-16.6 19.8-29.6 19.8H32c-12.9 0-24.6-7.8-29.6-19.8s-2.2-25.7 6.9-34.9l128-128zm0 429.3l-128-128c-9.2-9.2-11.9-22.9-6.9-34.9s16.6-19.8 29.6-19.8H288c12.9 0 24.6 7.8 29.6 19.8s2.2 25.7-6.9 34.9l-128 128c-12.5 12.5-32.8 12.5-45.3 0z"/></svg>

After

Width:  |  Height:  |  Size: 551 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.4.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M182.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-128 128c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8H288c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-128-128z"/></svg>

After

Width:  |  Height:  |  Size: 402 B

View File

@ -1,76 +0,0 @@
import React, { FC } from 'react';
import {
Table,
} from '@tanstack/react-table';
type PaginationProps = {
table: Table<any>;
};
const Pagination: FC<PaginationProps> = ({ table }) => {
return (
<div className="pagination-renderer flex items-center gap-2">
<button
className="border rounded p-1"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
{'<<'}
</button>
<button
className="border rounded p-1"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<button
className="border rounded p-1"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<button
className="border rounded p-1"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
{'>>'}
</button>
<span className="flex items-center gap-1 page-label">
<div>Page</div>
<strong>
{table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</strong>
</span>
<span className="flex items-center gap-1 go-to-page-label">
| Go to page:
<input
type="number"
defaultValue={table.getState().pagination.pageIndex + 1}
onChange={e => {
const page = e.target.value ? Number(e.target.value) - 1 : 0;
table.setPageIndex(page);
}}
className="border p-1 rounded w-16"
/>
</span>
<select
value={table.getState().pagination.pageSize}
onChange={e => {
table.setPageSize(Number(e.target.value));
}}
>
{[10, 20, 30, 40, 50].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</div>
);
};
export default Pagination;