Impl TrialTable in storybook

This commit is contained in:
porink0424
2024-02-16 17:54:03 +09:00
parent 9ad0aee5a4
commit 52a72aa5bd
9 changed files with 1242 additions and 100 deletions
+602 -58
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -13,9 +13,15 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@mui/system": "^5.15.9",
"@sqlite.org/sqlite-wasm": "^3.45.1-build1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recoil": "^0.7.7",
"sanitize.css": "^13.0.0"
},
"devDependencies": {
+16
View File
@@ -0,0 +1,16 @@
import { loadStorageFromFile } from "./utils/loadStorageFromFile";
const filePath = "db.sqlite3";
const res = await fetch(filePath);
const blob = await res.blob();
const file = new File([blob], filePath);
const mockStudies: Study[] = [];
await loadStorageFromFile(file, (value) => {
if (Array.isArray(value)) {
mockStudies.push(...value);
} else {
mockStudies.push(...value([]));
}
});
export { mockStudies };
+373
View File
@@ -0,0 +1,373 @@
import { Clear } from "@mui/icons-material";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import {
Collapse,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TablePagination,
TableRow,
TableSortLabel,
useTheme,
} from "@mui/material";
import { styled } from "@mui/system";
import { Fragment, useState } from "react";
import { DataGridColumn } from "./DataGridColumn";
type Order = "asc" | "desc";
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
type Value = any;
const defaultRowsPerPageOption = [10, 50, 100, { label: "All", value: -1 }];
interface RowFilter {
columnIdx: number;
value: Value;
}
function DataGrid<T>(props: {
columns: DataGridColumn<T>[];
rows: T[];
keyField: keyof T;
dense?: boolean;
collapseBody?: (rowIndex: number) => React.ReactNode;
initialRowsPerPage?: number;
rowsPerPageOption?: Array<number | { value: number; label: string }>;
defaultFilter?: (row: T) => boolean;
}): React.ReactElement {
const { columns, rows, keyField, dense, collapseBody, defaultFilter } = props;
let { initialRowsPerPage, rowsPerPageOption } = props;
const [order, setOrder] = useState<Order>("asc");
const [orderBy, setOrderBy] = useState<number>(0); // index of columns
const [page, setPage] = useState(0);
const [filters, setFilters] = useState<RowFilter[]>([]);
const getRowIndex = (row: T): number => {
return rows.findIndex((row2) => row[keyField] === row2[keyField]);
};
// Pagination
rowsPerPageOption = rowsPerPageOption || defaultRowsPerPageOption;
initialRowsPerPage = initialRowsPerPage // use first element as default
? initialRowsPerPage
: isNumber(rowsPerPageOption[0])
? rowsPerPageOption[0]
: rowsPerPageOption[0].value;
const [rowsPerPage, setRowsPerPage] = useState(initialRowsPerPage);
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
// Filtering
const fieldAlreadyFiltered = (columnIdx: number): boolean =>
filters.some((f) => f.columnIdx === columnIdx);
const handleClickFilterCell = (columnIdx: number, value: Value) => {
if (fieldAlreadyFiltered(columnIdx)) {
return;
}
const newFilters = [...filters, { columnIdx: columnIdx, value: value }];
setFilters(newFilters);
};
const clearFilter = (columnIdx: number): void => {
setFilters(filters.filter((f) => f.columnIdx !== columnIdx));
};
const filteredRows = rows.filter((row, rowIdx) => {
if (defaultFilter?.(row)) {
return false;
}
return filters.length === 0
? true
: filters.some((f) => {
if (columns.length <= f.columnIdx) {
console.log(
`columnIdx=${f.columnIdx} must be smaller than columns.length=${columns.length}`,
);
return true;
}
const toCellValue = columns[f.columnIdx].toCellValue;
if (toCellValue !== undefined) {
return toCellValue(rowIdx) === f.value;
}
const field = columns[f.columnIdx].field;
return row[field] === f.value;
});
});
// Sorting
const createSortHandler = (columnId: number) => () => {
const isAsc = orderBy === columnId && order === "asc";
setOrder(isAsc ? "desc" : "asc");
setOrderBy(columnId);
};
const sortedRows = stableSort<T>(filteredRows, order, orderBy, columns);
const currentPageRows =
rowsPerPage > 0
? sortedRows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
: sortedRows;
const emptyRows =
rowsPerPage - Math.min(rowsPerPage, sortedRows.length - page * rowsPerPage);
const RootDiv = styled("div")({
width: "100%",
});
const HiddenSpan = styled("span")({
border: 0,
clip: "rect(0 0 0 0)",
height: 1,
margin: -1,
overflow: "hidden",
padding: 0,
position: "absolute",
top: 20,
width: 1,
});
const TableHeaderCellSpan = styled("span")({
display: "inline-flex",
});
return (
<RootDiv>
<TableContainer>
<Table
aria-labelledby="tableTitle"
size={dense ? "small" : "medium"}
aria-label="data grid"
>
<TableHead>
<TableRow>
{collapseBody ? <TableCell /> : null}
{columns.map((column, columnIdx) => (
<TableCell
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={columnIdx}
padding={column.padding || "normal"}
sortDirection={orderBy === column.field ? order : false}
>
<TableHeaderCellSpan>
{column.sortable ? (
<TableSortLabel
active={orderBy === columnIdx}
direction={orderBy === columnIdx ? order : "asc"}
onClick={createSortHandler(columnIdx)}
>
{column.label}
{orderBy === column.field ? (
<HiddenSpan>
{order === "desc"
? "sorted descending"
: "sorted ascending"}
</HiddenSpan>
) : null}
</TableSortLabel>
) : (
column.label
)}
{column.filterable ? (
<IconButton
size={dense ? "small" : "medium"}
style={
fieldAlreadyFiltered(columnIdx)
? {}
: { visibility: "hidden" }
}
color="inherit"
onClick={() => {
clearFilter(columnIdx);
}}
>
<Clear />
</IconButton>
) : null}
</TableHeaderCellSpan>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{currentPageRows.map((row) => (
<DataGridRow<T>
columns={columns}
rowIndex={getRowIndex(row)}
row={row}
keyField={keyField}
collapseBody={collapseBody}
key={`${row[keyField]}`}
handleClickFilterCell={handleClickFilterCell}
/>
))}
{emptyRows > 0 && (
<TableRow style={{ height: (dense ? 33 : 53) * emptyRows }}>
<TableCell colSpan={6} />
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
rowsPerPageOptions={rowsPerPageOption}
component="div"
count={filteredRows.length}
rowsPerPage={rowsPerPage}
page={page}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
/>
</RootDiv>
);
}
function DataGridRow<T>(props: {
columns: DataGridColumn<T>[];
rowIndex: number;
row: T;
keyField: keyof T;
collapseBody?: (rowIndex: number) => React.ReactNode;
handleClickFilterCell: (columnIdx: number, value: Value) => void;
}) {
const {
columns,
rowIndex,
row,
keyField,
collapseBody,
handleClickFilterCell,
} = props;
const [open, setOpen] = useState(false);
const theme = useTheme();
const FilterableDiv = styled("div")({
color: theme.palette.primary.main,
textDecoration: "underline",
cursor: "pointer",
});
return (
<Fragment>
<TableRow hover tabIndex={-1}>
{collapseBody ? (
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
) : null}
{columns.map((column, columnIndex) => {
const cellItem = column.toCellValue
? column.toCellValue(rowIndex)
: // TODO(c-bata): Avoid this implicit type conversion.
(row[column.field] as number | string | null | undefined);
return column.filterable ? (
<TableCell
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
padding={column.padding || "normal"}
onClick={() => {
const value =
column.toCellValue !== undefined
? column.toCellValue(rowIndex)
: row[column.field];
handleClickFilterCell(columnIndex, value);
}}
>
<FilterableDiv>{cellItem}</FilterableDiv>
</TableCell>
) : (
<TableCell
key={`${row[keyField]}:${column.field.toString()}:${columnIndex}`}
padding={column.padding || "normal"}
>
{cellItem}
</TableCell>
);
})}
</TableRow>
{collapseBody ? (
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
{collapseBody(rowIndex)}
</Collapse>
</TableCell>
</TableRow>
) : null}
</Fragment>
);
}
function getComparator<T>(
order: Order,
columns: DataGridColumn<T>[],
orderBy: number,
): (a: T, b: T) => number {
return order === "desc"
? (a, b) => descendingComparator<T>(a, b, columns, orderBy)
: (a, b) => -descendingComparator<T>(a, b, columns, orderBy);
}
function descendingComparator<T>(
a: T,
b: T,
columns: DataGridColumn<T>[],
orderBy: number,
): number {
const field = columns[orderBy].field;
if (b[field] < a[field]) {
return -1;
}
if (b[field] > a[field]) {
return 1;
}
return 0;
}
function stableSort<T>(
array: T[],
order: Order,
orderBy: number,
columns: DataGridColumn<T>[],
) {
// TODO(c-bata): Refactor here by implementing as the same comparator interface.
const less = columns[orderBy].less;
const comparator = getComparator(order, columns, orderBy);
const stabilizedThis = array.map((el, index) => [el, index] as [T, number]);
stabilizedThis.sort((a, b) => {
if (less) {
const ascending = order === "asc";
const result = ascending
? -less(a[0], b[0], ascending)
: less(a[0], b[0], ascending);
if (result !== 0) return result;
} else {
const result = comparator(a[0], b[0]);
if (result !== 0) return result;
}
return a[1] - b[1];
});
return stabilizedThis.map((el) => el[0]);
}
const isNumber = (
rowsPerPage: number | { value: number; label: string },
): rowsPerPage is number => {
return typeof rowsPerPage === "number";
};
export { DataGrid };
+9
View File
@@ -0,0 +1,9 @@
export interface DataGridColumn<T> {
field: keyof T;
label: string;
sortable?: boolean;
less?: (a: T, b: T, ascending: boolean) => number;
filterable?: boolean;
toCellValue?: (rowIndex: number) => string | React.ReactNode;
padding?: "normal" | "checkbox" | "none";
}
@@ -0,0 +1,24 @@
import { Meta, StoryObj } from "@storybook/react";
import { mockStudies } from "../MockStudies";
import { TrialTable } from "./TrialTable";
const meta: Meta<typeof TrialTable> = {
component: TrialTable,
title: "TrialTable",
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof TrialTable>;
export const MockStudy1: Story = {
args: {
study: mockStudies[0],
},
};
export const MockStudy2: Story = {
args: {
study: mockStudies[1],
},
};
+162
View File
@@ -0,0 +1,162 @@
import { FC } from "react";
import { DataGrid } from "./DataGrid";
import { DataGridColumn } from "./DataGridColumn";
export const TrialTable: FC<{
study: Study;
initialRowsPerPage?: number;
}> = ({ study, initialRowsPerPage }) => {
const trials: Trial[] = study.trials;
const columns: DataGridColumn<Trial>[] = [
{ field: "number", label: "Number", sortable: true, padding: "none" },
{
field: "state",
label: "State",
sortable: true,
filterable: true,
padding: "none",
toCellValue: (i) => trials[i].state.toString(),
},
];
if (study === null || study.directions.length === 1) {
columns.push({
field: "values",
label: "Value",
sortable: true,
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[0];
const secondVal = secondEl.values?.[0];
if (firstVal === secondVal) {
return 0;
}
if (firstVal === undefined) {
return ascending ? -1 : 1;
} else if (secondVal === undefined) {
return ascending ? 1 : -1;
}
if (firstVal === "-inf" || secondVal === "inf") {
return 1;
} else if (secondVal === "-inf" || firstVal === "inf") {
return -1;
}
return firstVal < secondVal ? 1 : -1;
},
toCellValue: (i) => {
if (trials[i].values === undefined) {
return null;
}
return trials[i].values?.[0];
},
});
} else {
const objectiveColumns: DataGridColumn<Trial>[] = study.directions.map(
(_s, objectiveId) => ({
field: "values",
label: `Objective ${objectiveId}`,
sortable: true,
less: (firstEl, secondEl, ascending): number => {
const firstVal = firstEl.values?.[objectiveId];
const secondVal = secondEl.values?.[objectiveId];
if (firstVal === secondVal) {
return 0;
}
if (firstVal === undefined) {
return ascending ? -1 : 1;
} else if (secondVal === undefined) {
return ascending ? 1 : -1;
}
if (firstVal === "-inf" || secondVal === "inf") {
return 1;
} else if (secondVal === "-inf" || firstVal === "inf") {
return -1;
}
return firstVal < secondVal ? 1 : -1;
},
toCellValue: (i) => {
if (trials[i].values === undefined) {
return null;
}
return trials[i].values?.[objectiveId];
},
}),
);
columns.push(...objectiveColumns);
}
study.union_search_space.forEach((s) => {
columns.push({
field: "params",
label: `Param ${s.name}`,
toCellValue: (i) =>
trials[i].params.find((p) => p.name === s.name)?.param_external_value ??
null,
sortable: true,
filterable: false,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.params.find(
(p) => p.name === s.name,
)?.param_internal_value;
const secondVal = secondEl.params.find(
(p) => p.name === s.name,
)?.param_internal_value;
if (firstVal === secondVal) {
return 0;
} else if (firstVal && secondVal) {
return firstVal < secondVal ? 1 : -1;
} else if (firstVal) {
return -1;
} else {
return 1;
}
},
});
});
study.union_user_attrs.forEach((attr_spec) => {
columns.push({
field: "user_attrs",
label: `UserAttribute ${attr_spec.key}`,
toCellValue: (i) =>
trials[i].user_attrs.find((attr) => attr.key === attr_spec.key)
?.value || null,
sortable: attr_spec.sortable,
filterable: false,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
less: (firstEl, secondEl, _): number => {
const firstVal = firstEl.user_attrs.find(
(attr) => attr.key === attr_spec.key,
)?.value;
const secondVal = secondEl.user_attrs.find(
(attr) => attr.key === attr_spec.key,
)?.value;
if (firstVal === secondVal) {
return 0;
} else if (firstVal && secondVal) {
return firstVal < secondVal ? 1 : -1;
} else if (firstVal) {
return -1;
} else {
return 1;
}
},
});
});
return (
<DataGrid<Trial>
columns={columns}
rows={trials}
keyField={"trial_id"}
dense={false}
initialRowsPerPage={initialRowsPerPage}
/>
);
};
+25 -16
View File
@@ -2,22 +2,31 @@ import { SetterOrUpdater } from "recoil";
import { loadJournalStorage } from "./journalStorage";
import { loadSQLite3Storage } from "./sqlite3";
export const loadStorageFromFile = (
const readFile = async (file: File) => {
return new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
const arrayBuffer = reader.result as ArrayBuffer | null;
if (arrayBuffer !== null) {
resolve(arrayBuffer);
} else {
reject(new Error("Failed to load file"));
}
});
reader.readAsArrayBuffer(file);
});
};
export const loadStorageFromFile = async (
file: File,
setStudies: SetterOrUpdater<Study[]>,
): void => {
const r = new FileReader();
r.addEventListener("load", () => {
const arrayBuffer = r.result as ArrayBuffer | null;
if (arrayBuffer !== null) {
const header = new Uint8Array(arrayBuffer, 0, 16);
const headerString = new TextDecoder().decode(header);
if (headerString === "SQLite format 3\u0000") {
loadSQLite3Storage(arrayBuffer, setStudies);
} else {
loadJournalStorage(arrayBuffer, setStudies);
}
}
});
r.readAsArrayBuffer(file);
) => {
const arrayBuffer = await readFile(file);
const header = new Uint8Array(arrayBuffer, 0, 16);
const headerString = new TextDecoder().decode(header);
if (headerString === "SQLite format 3\u0000") {
await loadSQLite3Storage(arrayBuffer, setStudies);
} else {
loadJournalStorage(arrayBuffer, setStudies);
}
};
+25 -26
View File
@@ -9,11 +9,11 @@ type SQLite3DB = {
}): void;
};
export const loadSQLite3Storage = (
export const loadSQLite3Storage = async (
arrayBuffer: ArrayBuffer,
setter: SetterOrUpdater<Study[]>,
): void => {
sqlite3InitModule({
) => {
const sqlite3 = await sqlite3InitModule({
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
print: (...args: any): void => {
console.log(args);
@@ -23,30 +23,29 @@ export const loadSQLite3Storage = (
console.log(args);
},
// @ts-ignore
}).then((sqlite3) => {
const p = sqlite3.wasm.allocFromTypedArray(arrayBuffer);
const db = new sqlite3.oo1.DB();
const rc = sqlite3.capi.sqlite3_deserialize(
// @ts-ignore
db.pointer,
"main",
p,
arrayBuffer.byteLength,
arrayBuffer.byteLength,
sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE,
);
db.checkRc(rc);
try {
const schemaVersion = getSchemaVersion(db);
if (!isSupportedSchema(schemaVersion)) {
return;
}
const studies = getStudies(db, schemaVersion);
setter((prev) => [...prev, ...studies]);
} finally {
db.close();
}
});
const p = sqlite3.wasm.allocFromTypedArray(arrayBuffer);
const db = new sqlite3.oo1.DB();
const rc = sqlite3.capi.sqlite3_deserialize(
// @ts-ignore
db.pointer,
"main",
p,
arrayBuffer.byteLength,
arrayBuffer.byteLength,
sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE,
);
db.checkRc(rc);
try {
const schemaVersion = getSchemaVersion(db);
if (!isSupportedSchema(schemaVersion)) {
return;
}
const studies = getStudies(db, schemaVersion);
setter((prev) => [...prev, ...studies]);
} finally {
db.close();
}
};
const getSchemaVersion = (db: SQLite3DB): string => {