diff --git a/chartlets.js/CHANGES.md b/chartlets.js/CHANGES.md index a89a622..43bc6cc 100644 --- a/chartlets.js/CHANGES.md +++ b/chartlets.js/CHANGES.md @@ -6,6 +6,7 @@ * New (MUI) components - `DataGrid` - `Dialog` + - `Table` ## Version 0.1.3 (from 2025/01/28) diff --git a/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx b/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx new file mode 100644 index 0000000..e196d30 --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Table.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { Table } from "@/plugins/mui/Table"; +import { createChangeHandler } from "@/plugins/mui/common.test"; + +describe("Table", () => { + const rows = [ + ["John", "Doe"], + ["Johnie", "Undoe"], + ]; + const columns = [ + { id: "firstName", label: "First Name" }, + { id: "lastName", label: "Last Name" }, + ]; + + it("should render the Table component", () => { + render( + {}} + />, + ); + + const table = screen.getByRole("table"); + expect(table).toBeDefined(); + columns.forEach((column) => { + expect(screen.getByText(column.label)).toBeInTheDocument(); + }); + rows.forEach((row, index) => { + expect(screen.getByText(row[index])).toBeInTheDocument(); + }); + }); + + it("should not render the Table component when no columns provided", () => { + render(
{}} />); + + const table = screen.queryByRole("table"); + expect(table).toBeNull(); + }); + + it("should not render the Table component when no rows provided", () => { + render(
{}} />); + + const table = screen.queryByRole("table"); + expect(table).toBeNull(); + }); + + it("should call onChange on row click", () => { + const { recordedEvents, onChange } = createChangeHandler(); + render( +
, + ); + + fireEvent.click(screen.getAllByRole("row")[1]); + expect(recordedEvents.length).toEqual(1); + expect(recordedEvents[0]).toEqual({ + componentType: "Table", + id: "table", + property: "value", + value: { + firstName: "John", + lastName: "Doe", + }, + }); + }); +}); diff --git a/chartlets.js/packages/lib/src/plugins/mui/Table.tsx b/chartlets.js/packages/lib/src/plugins/mui/Table.tsx new file mode 100644 index 0000000..c5dbd78 --- /dev/null +++ b/chartlets.js/packages/lib/src/plugins/mui/Table.tsx @@ -0,0 +1,110 @@ +import { + Paper, + Table as MuiTable, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from "@mui/material"; +import type { ComponentProps, ComponentState } from "@/index"; +import type { SxProps } from "@mui/system"; + +interface TableCellProps { + id: string | number; + size?: "medium" | "small"; + align?: "inherit" | "left" | "center" | "right" | "justify"; + sx?: SxProps; +} + +interface TableColumn extends TableCellProps { + label: string; +} + +interface TableState extends ComponentState { + rows?: (string | number | boolean | undefined)[][]; + columns?: TableColumn[]; + hover?: boolean; + stickyHeader?: boolean; +} + +interface TableProps extends ComponentProps, TableState {} + +export const Table = ({ + type, + id, + style, + rows, + columns, + hover, + stickyHeader, + onChange, +}: TableProps) => { + if (!columns || columns.length === 0) { + return
No columns provided.
; + } + + if (!rows || rows.length === 0) { + return
No rows provided.
; + } + + const handleRowClick = (row: (string | number | boolean | undefined)[]) => { + const rowData = row.reduce( + (acc, cell, cellIndex) => { + const columnId = columns[cellIndex]?.id; + if (columnId) { + acc[columnId] = cell; + } + return acc; + }, + {} as Record, + ); + if (id) { + onChange({ + componentType: type, + id: id, + property: "value", + value: rowData, + }); + } + }; + + return ( + + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + {rows.map((row, row_index) => ( + handleRowClick(row)} + > + {row?.map((item, item_index) => ( + + {item} + + ))} + + ))} + + + + ); +}; diff --git a/chartlets.js/packages/lib/src/plugins/mui/index.ts b/chartlets.js/packages/lib/src/plugins/mui/index.ts index 9b10ac9..52eec87 100644 --- a/chartlets.js/packages/lib/src/plugins/mui/index.ts +++ b/chartlets.js/packages/lib/src/plugins/mui/index.ts @@ -14,6 +14,7 @@ import { Typography } from "./Typography"; import { Slider } from "./Slider"; import { DataGrid } from "@/plugins/mui/DataGrid"; import { Dialog } from "@/plugins/mui/Dialog"; +import { Table } from "@/plugins/mui/Table"; export default function mui(): Plugin { return { @@ -31,6 +32,7 @@ export default function mui(): Plugin { ["Select", Select], ["Slider", Slider], ["Switch", Switch], + ["Table", Table], ["Tabs", Tabs], ["Typography", Typography], ], diff --git a/chartlets.py/CHANGES.md b/chartlets.py/CHANGES.md index 683b9a4..c770c13 100644 --- a/chartlets.py/CHANGES.md +++ b/chartlets.py/CHANGES.md @@ -3,6 +3,7 @@ * New (MUI) components - `DataGrid` - `Dialog` + - `Table` ## Version 0.1.3 (from 2025/01/28) diff --git a/chartlets.py/chartlets/components/__init__.py b/chartlets.py/chartlets/components/__init__.py index 6fdb1e3..80df8b1 100644 --- a/chartlets.py/chartlets/components/__init__.py +++ b/chartlets.py/chartlets/components/__init__.py @@ -15,6 +15,7 @@ from .slider import Slider from .switch import Switch from .datagrid import DataGrid +from .table import Table from .tabs import Tab from .tabs import Tabs from .typography import Typography diff --git a/chartlets.py/chartlets/components/table.py b/chartlets.py/chartlets/components/table.py new file mode 100644 index 0000000..2f0405a --- /dev/null +++ b/chartlets.py/chartlets/components/table.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import Literal, TypedDict, TypeAlias +from chartlets import Component + + +class TableCellProps(TypedDict, total=False): + """Represents common properties of a table cell.""" + + id: str | int | float + """The unique identifier for the cell.""" + + size: Literal['medium', 'small'] | str | None + """The size of the cell.""" + + align: Literal["inherit", "left", "center", "right", "justify"] | None + """The alignment of the cell content.""" + + +class TableColumn(TableCellProps): + """Defines a column in the table.""" + + label: str + """The display label for the column header.""" + + +TableRow: TypeAlias = list[list[str | int | float | bool | None]] + + +@dataclass(frozen=True) +class Table(Component): + """A basic Table with configurable rows and columns.""" + + columns: list[TableColumn] | None = None + """The columns to display in the table.""" + + rows: TableRow | None = None + """The rows of data to display in the table.""" + + hover: bool | None = None + """A boolean indicating whether to highlight a row when hovered over""" + + stickyHeader: bool | None = None + """A boolean to set the header of the table sticky""" diff --git a/chartlets.py/demo/my_extension/__init__.py b/chartlets.py/demo/my_extension/__init__.py index e1d4538..265f76a 100644 --- a/chartlets.py/demo/my_extension/__init__.py +++ b/chartlets.py/demo/my_extension/__init__.py @@ -4,6 +4,7 @@ from .my_panel_3 import panel as my_panel_3 from .my_panel_4 import panel as my_panel_4 from .my_panel_5 import panel as my_panel_5 +from .my_panel_6 import panel as my_panel_6 ext = Extension(__name__) ext.add(my_panel_1) @@ -11,3 +12,4 @@ ext.add(my_panel_3) ext.add(my_panel_4) ext.add(my_panel_5) +ext.add(my_panel_6) diff --git a/chartlets.py/demo/my_extension/my_panel_6.py b/chartlets.py/demo/my_extension/my_panel_6.py new file mode 100644 index 0000000..7622c67 --- /dev/null +++ b/chartlets.py/demo/my_extension/my_panel_6.py @@ -0,0 +1,58 @@ +from chartlets import Component, Input, Output +from chartlets.components import Box, Typography, Table + +from server.context import Context +from server.panel import Panel + +from chartlets.components.table import TableColumn, TableRow + +panel = Panel(__name__, title="Panel F") + + +# noinspection PyUnusedLocal +@panel.layout() +def render_panel( + ctx: Context, +) -> Component: + columns: list[TableColumn] = [ + {"id": "id", "label": "ID", "sortDirection": "desc"}, + { + "id": "firstName", + "label": "First Name", + "align": "left", + "sortDirection": "desc", + }, + {"id": "lastName", "label": "Last Name", "align": "center"}, + {"id": "age", "label": "Age"}, + ] + + rows: TableRow = [ + ["1", "John", "Doe", 30], + ["2", "Jane", "Smith", 25], + ["3", "Peter", "Jones", 40], + ] + + table = Table(id="table", rows=rows, columns=columns, hover=True) + + title_text = Typography(id="title_text", children=["Basic Table"]) + info_text = Typography(id="info_text", children=["Click on any row."]) + + return Box( + style={ + "display": "flex", + "flexDirection": "column", + "width": "100%", + "height": "100%", + "gap": "6px", + }, + children=[title_text, table, info_text], + ) + + +# noinspection PyUnusedLocal +@panel.callback(Input("table"), Output("info_text", "children")) +def update_info_text( + ctx: Context, + table_row: int, +) -> list[str]: + return [f"The clicked row value is {table_row}."] diff --git a/chartlets.py/tests/components/table_test.py b/chartlets.py/tests/components/table_test.py new file mode 100644 index 0000000..0248c39 --- /dev/null +++ b/chartlets.py/tests/components/table_test.py @@ -0,0 +1,38 @@ +from chartlets.components.table import TableColumn, Table, TableRow +from tests.component_test import make_base + + +class TableTest(make_base(Table)): + + def test_is_json_serializable_empty(self): + self.assert_is_json_serializable( + self.cls(), + { + "type": "Table", + }, + ) + + columns: list[TableColumn] = [ + {"id": "id", "label": "ID"}, + {"id": "firstName", "label": "First Name"}, + {"id": "lastName", "label": "Last Name"}, + {"id": "age", "label": "Age"}, + ] + rows: TableRow = [ + ["John", "Doe", 30], + ["Jane", "Smith", 25], + ["Johnie", "Undoe", 40], + ] + hover: bool = True + style = {"background-color": "lightgray", "width": "100%"} + + self.assert_is_json_serializable( + self.cls(columns=columns, rows=rows, style=style, hover=hover), + { + "type": "Table", + "columns": columns, + "rows": rows, + "style": style, + "hover": hover, + }, + )