Pe 193 csv viewer plugin (#32)

* init

* render table

* add packages

* filtering and sorting

* init pagination

* partially working min-max

* fix broken style

* styles fixes
This commit is contained in:
Red J Adaya 2023-10-10 06:24:59 +08:00 committed by GitHub
parent 336ac776d4
commit ecff69eee8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 1611 additions and 9 deletions

View File

@ -5,6 +5,8 @@
"license": "Proprietary",
"dependencies": {
"@monaco-editor/react": "^4.5.1",
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.3",
"autobind-decorator": "^2.4.0",
"classnames": "^2.3.1",
"dayjs": "^1.11.3",
@ -15,6 +17,7 @@
"monaco-editor": "^0.41.0",
"mustache": "^4.2.0",
"node-fetch": "^3.2.10",
"papaparse": "^5.4.1",
"react": "^18.1.0",
"react-dom": "^18.1.0",
"react-json-view": "^1.21.3",
@ -49,6 +52,7 @@
"@types/classnames": "^2.3.1",
"@types/electron": "^1.6.10",
"@types/node": "^18.0.3",
"@types/papaparse": "^5.3.9",
"@types/react": "^18.0.12",
"@types/uuid": "9.0.0",
"babel-loader": "^9.1.3",

123
src/plugins/csv/csv.less Normal file
View File

@ -0,0 +1,123 @@
@import "../../index.less";
.csv-renderer {
.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;
tbody {
border-bottom: 1px solid rgb(146, 146, 146);
}
th {
color: white;
border-bottom: 1px solid rgb(146, 146, 146);
border-right: 1px solid rgb(146, 146, 146);
padding: 2px 10px 4px 10px;
.min-max-renderer {
min-width: 200px;
display: flex;
.search-renderer {
flex: 1;
input {
width: 99%;
}
}
.search-renderer:first-child {
margin-right: 3px;
}
}
}
tr {
td {
border-right: 1px solid rgb(146, 146, 146);
padding: 2px 10px;
}
td:last-child {
border-right: none;
}
}
}
.pagination-renderer {
.page-label {
div {
padding-right: 6px;
}
strong {
color: white;
}
}
}
}

289
src/plugins/csv/csv.tsx Normal file
View File

@ -0,0 +1,289 @@
import React, { FC, useEffect, useState, useRef, useMemo } from "react";
import { RendererContext, RendererOpts, LineStateType, RendererModelContainerApi } from "../../types/types";
import { GlobalModel } from "../../model/model";
import Papa from 'papaparse';
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 "./csv.less";
declare module '@tanstack/table-core' {
interface FilterFns {
fuzzy: FilterFn<unknown>
}
interface FilterMeta {
itemRank: RankingInfo
}
}
type CSVRow = {
[key: string]: string | number;
};
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
// Rank the item
const itemRank = rankItem(row.getValue(columnId), value)
// Store the itemRank info
addMeta({
itemRank,
})
// 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 {
data: Blob;
cmdstr: string;
cwd: string;
readOnly: boolean;
notFound: boolean;
exitcode: number;
context: RendererContext;
opts: RendererOpts;
savedHeight: number;
scrollToBringIntoViewport: () => void;
lineState: LineStateType;
isSelected: boolean;
shouldFocus: boolean;
rendererApi: RendererModelContainerApi;
}
interface State {
content: string | null;
message: { status: string; text: string } | null;
isPreviewerAvailable: boolean;
showReadonly: boolean;
}
const columnHelper = createColumnHelper<any>();
const CSVRenderer: FC<Props> = (props: Props) => {
const csvCacheRef = useRef(new Map<string, string>());
const [state, setState] = useState<State>({
content: null,
message: null,
isPreviewerAvailable: false,
showReadonly: true,
});
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [globalFilter, setGlobalFilter] = React.useState('')
const filePath = props.lineState["prompt:file"];
const { screenId, lineId } = props.context;
const cacheKey = `${screenId}-${lineId}-${filePath}`;
// Parse the CSV data
const parsedData = useMemo<CSVRow[]>(() => {
if (!state.content) return [];
const results = Papa.parse(state.content, { header: true });
return results.data.map(row => {
return Object.fromEntries(
Object.entries(row as CSVRow).map(([key, value]) => {
if (typeof value === 'string') {
const numberValue = parseFloat(value);
if (!isNaN(numberValue) && String(numberValue) === value) {
return [key, numberValue];
}
}
return [key, value];
})
) as CSVRow;
});
}, [state.content]);
// Column Definitions
const columns = useMemo(() => {
if (parsedData.length === 0) {
return [];
}
const headers = Object.keys(parsedData[0]);
return headers.map(header =>
columnHelper.accessor(header, {
header: () => header,
cell: info => info.renderValue(),
})
);
}, [parsedData]);
useEffect(() => {
const content = csvCacheRef.current.get(cacheKey);
if (content) {
setState((prevState) => ({ ...prevState, content }));
} else {
props.data.text().then((content: string) => {
setState((prevState) => ({ ...prevState, content }));
csvCacheRef.current.set(cacheKey, content);
});
}
}, []);
const getMessage = () => (
<div style={{ position: "absolute", bottom: "-3px", left: "14px" }}>
<div
className="message"
style={{
fontSize: GlobalModel.termFontSize.get(),
background: `${state.message?.status === "error" ? "red" : "#4e9a06"}`,
}}
>
{state.message?.text}
</div>
</div>
);
const { exitcode } = props;
const { content, message } = state;
const table = useReactTable({
data: parsedData,
columns,
filterFns: {
fuzzy: fuzzyFilter,
},
state: {
columnFilters,
globalFilter,
},
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: fuzzyFilter,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
});
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
: (
<>
<div
{...{
className: header.column.getCanSort()
? 'cursor-pointer select-none'
: '',
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</div>
{header.column.getCanFilter() ? (
<div>
<Filter column={header.column} table={table} />
</div>
) : null}
</>
)}
</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>
))}
</tr>
))}
</tbody>
</table>
<Pagination table={table} />
{message && getMessage()}
</div>
);
}
export { CSVRenderer };

View File

@ -0,0 +1,78 @@
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,76 @@
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;

View File

@ -0,0 +1,32 @@
import React, { FC, useEffect, useState } from "react";
const DebouncedInput: FC<{
value: string | number;
onChange: (value: string | number) => void;
debounce?: number;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>> = ({
value: initialValue,
onChange,
debounce = 500,
...props
}) => {
const [value, setValue] = useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
useEffect(() => {
const timeout = setTimeout(() => {
onChange(value);
}, debounce);
return () => clearTimeout(timeout);
}, [value]);
return <div className="search-renderer">
<input {...props} value={value} onChange={e => setValue(e.target.value)} />
</div>;
};
export default DebouncedInput

View File

@ -3,6 +3,7 @@ import { SimpleImageRenderer } from "./image/image";
import { SimpleMarkdownRenderer } from "./markdown/markdown";
import { SourceCodeRenderer } from "./code/code";
import { SimpleMustacheRenderer } from "./mustache/mustache";
import { CSVRenderer } from "./csv/csv";
import { OpenAIRenderer, OpenAIRendererModel } from "./openai/openai";
import { isBlank } from "../util/util";
import { sprintf } from "sprintf-js";
@ -64,6 +65,17 @@ const OpenAIPlugin: RendererPluginType = {
modelCtor: () => new OpenAIRendererModel(),
};
const CSVPlugin: RendererPluginType = {
name: "csv",
rendererType: "simple",
heightType: "pixels",
dataType: "blob",
collapseType: "hide",
globalCss: null,
mimeTypes: ["text/csv"],
simpleComponent: CSVRenderer,
};
class PluginModelClass {
rendererPlugins: RendererPluginType[] = [];
@ -100,6 +112,7 @@ if ((window as any).PluginModel == null) {
PluginModel.registerRendererPlugin(CodePlugin);
PluginModel.registerRendererPlugin(OpenAIPlugin);
PluginModel.registerRendererPlugin(MustachePlugin);
PluginModel.registerRendererPlugin(CSVPlugin);
(window as any).PluginModel = PluginModel;
}

1005
yarn.lock

File diff suppressed because it is too large Load Diff