diff --git a/chartlets.js/src/lib/actions/helpers/applyStateChangeRequests.test.ts b/chartlets.js/src/lib/actions/helpers/applyStateChangeRequests.test.ts index 45db5b6b..b78a9ae7 100644 --- a/chartlets.js/src/lib/actions/helpers/applyStateChangeRequests.test.ts +++ b/chartlets.js/src/lib/actions/helpers/applyStateChangeRequests.test.ts @@ -1,23 +1,19 @@ import { describe, it, expect } from "vitest"; +import { type ComponentState } from "@/lib"; import { type ContribPoint } from "@/lib/types/model/extension"; import { type StateChangeRequest } from "@/lib/types/model/callback"; -import { - type BoxState, - type ComponentState, - type PlotState, -} from "@/lib/types/state/component"; import { type ContributionState } from "@/lib/types/state/contribution"; import { applyComponentStateChange, applyContributionChangeRequests, } from "./applyStateChangeRequests"; -const componentTree: ComponentState = { +const componentTree = { type: "Box", id: "b1", children: [ - { type: "Plot", id: "p1", chart: null } as PlotState, + { type: "Plot", id: "p1", chart: null }, { type: "Box", id: "b2", @@ -115,7 +111,7 @@ describe("Test that applyComponentStateChange()", () => { }); it("replaces state if property is empty string", () => { - const value: BoxState = { + const value = { type: "Box", id: "b1", children: ["Hello", "World"], diff --git a/chartlets.js/src/lib/actions/helpers/getInputValues.test.ts b/chartlets.js/src/lib/actions/helpers/getInputValues.test.ts index fbf314a3..145643d1 100644 --- a/chartlets.js/src/lib/actions/helpers/getInputValues.test.ts +++ b/chartlets.js/src/lib/actions/helpers/getInputValues.test.ts @@ -1,16 +1,15 @@ import { describe, it, expect } from "vitest"; -import type { ComponentState, PlotState } from "@/lib/types/state/component"; import { getInputValueFromComponent, getInputValueFromState, } from "./getInputValues"; -const componentState: ComponentState = { +const componentState = { type: "Box", id: "b1", children: [ - { type: "Plot", id: "p1", chart: null } as PlotState, + { type: "Plot", id: "p1", chart: null }, { type: "Box", id: "b2", diff --git a/chartlets.js/src/lib/components/ComponentChildren.tsx b/chartlets.js/src/lib/component/Children.tsx similarity index 86% rename from chartlets.js/src/lib/components/ComponentChildren.tsx rename to chartlets.js/src/lib/component/Children.tsx index 18ff8193..cbed58e4 100644 --- a/chartlets.js/src/lib/components/ComponentChildren.tsx +++ b/chartlets.js/src/lib/component/Children.tsx @@ -5,12 +5,12 @@ import { } from "@/lib/types/state/component"; import { Component } from "./Component"; -export interface ComponentChildrenProps { +export interface ChildrenProps { nodes?: ComponentNode[]; onChange: ComponentChangeHandler; } -export function ComponentChildren({ nodes, onChange }: ComponentChildrenProps) { +export function Children({ nodes, onChange }: ChildrenProps) { if (!nodes || nodes.length === 0) { return null; } diff --git a/chartlets.js/src/lib/component/Component.tsx b/chartlets.js/src/lib/component/Component.tsx new file mode 100644 index 00000000..2cad0537 --- /dev/null +++ b/chartlets.js/src/lib/component/Component.tsx @@ -0,0 +1,20 @@ +import { type ComponentChangeHandler } from "@/lib/types/state/event"; +import { registry } from "@/lib/component/Registry"; + +export interface ComponentProps { + type: string; + onChange: ComponentChangeHandler; +} + +export function Component(props: ComponentProps) { + const { type: componentType } = props; + const ActualComponent = registry.lookup(componentType); + if (typeof ActualComponent === "function") { + return ; + } else { + console.error( + `chartlets: invalid component type encountered: ${componentType}`, + ); + return null; + } +} diff --git a/chartlets.js/src/lib/component/Registry.test.ts b/chartlets.js/src/lib/component/Registry.test.ts new file mode 100644 index 00000000..bbbff336 --- /dev/null +++ b/chartlets.js/src/lib/component/Registry.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; + +import { RegistryImpl } from "@/lib/component/Registry"; + +describe("Test that RegistryImpl", () => { + it("works", () => { + const registry = new RegistryImpl(); + expect(registry.types).toEqual([]); + + const A = () => void 0; + const B = () => void 0; + const C = () => void 0; + const unregisterA = registry.register(A); + const unregisterB = registry.register(B); + const unregisterC = registry.register(C); + + expect(registry.lookup("A")).toBe(A); + expect(registry.lookup("B")).toBe(B); + expect(registry.lookup("C")).toBe(C); + expect(new Set(registry.types)).toEqual(new Set(["A", "B", "C"])); + + unregisterA(); + expect(registry.lookup("A")).toBeUndefined(); + expect(registry.lookup("B")).toBe(B); + expect(registry.lookup("C")).toBe(C); + expect(new Set(registry.types)).toEqual(new Set(["B", "C"])); + + unregisterB(); + expect(registry.lookup("A")).toBeUndefined(); + expect(registry.lookup("B")).toBeUndefined(); + expect(registry.lookup("C")).toBe(C); + expect(new Set(registry.types)).toEqual(new Set(["C"])); + + const C2 = () => void 0; + const unregisterC2 = registry.register(C2, "C"); + expect(registry.lookup("A")).toBeUndefined(); + expect(registry.lookup("B")).toBeUndefined(); + expect(registry.lookup("C")).toBe(C2); + expect(new Set(registry.types)).toEqual(new Set(["C"])); + + unregisterC2(); + expect(registry.lookup("A")).toBeUndefined(); + expect(registry.lookup("B")).toBeUndefined(); + expect(registry.lookup("C")).toBe(C); + expect(new Set(registry.types)).toEqual(new Set(["C"])); + + unregisterC(); + expect(registry.lookup("A")).toBeUndefined(); + expect(registry.lookup("B")).toBeUndefined(); + expect(registry.lookup("C")).toBeUndefined(); + expect(registry.types).toEqual([]); + }); +}); diff --git a/chartlets.js/src/lib/component/Registry.ts b/chartlets.js/src/lib/component/Registry.ts new file mode 100644 index 00000000..c58e6a6e --- /dev/null +++ b/chartlets.js/src/lib/component/Registry.ts @@ -0,0 +1,68 @@ +import type { FC } from "react"; +import type { ComponentProps } from "@/lib/component/Component"; + +/** + * A registry for Chartlets components. + */ +export interface Registry { + /** + * Register a React component that renders a Chartlets component. + * + * @param component A functional React component. + * @param type The Chartlets component's type name. + * If not provided, `component.name` is used. + */ + register(component: FC, type?: string): () => void; + + /** + * Lookup the component of the provided type. + * + * @param type The Chartlets component's type name. + */ + lookup(type: string): FC | undefined; + + /** + * Get the type names of all registered components. + */ + types: string[]; +} + +// export for testing only +export class RegistryImpl implements Registry { + private components = new Map>(); + + register(component: FC, type?: string): () => void { + type = type || component.name; + const oldComponent = this.components.get(type); + this.components.set(type, component); + return () => { + if (typeof oldComponent === "function") { + this.components.set(type, oldComponent); + } else { + this.components.delete(type); + } + }; + } + + lookup(type: string): FC | undefined { + return this.components.get(type); + } + + get types(): string[] { + return Array.from(this.components.keys()); + } +} + +/** + * The Chartly component registry. + * + * Use `registry.register(C)` to register your own component `C`. + * + * `C` must be a functional React component with at least the following + * two properties: + * + * - `type: string`: your component's type name. + * - `onChange: ComponentChangeHandler`: an event handler + * that your component may call to signal change events. + */ +export const registry = new RegistryImpl(); diff --git a/chartlets.js/src/lib/components/Box.tsx b/chartlets.js/src/lib/components/Box.tsx index 5fe17018..b6335544 100644 --- a/chartlets.js/src/lib/components/Box.tsx +++ b/chartlets.js/src/lib/components/Box.tsx @@ -1,17 +1,17 @@ import MuiBox from "@mui/material/Box"; -import { type BoxState } from "@/lib/types/state/component"; -import { type ComponentChangeHandler } from "@/lib/types/state/event"; -import { ComponentChildren } from "./ComponentChildren"; +import type { ComponentState } from "@/lib/types/state/component"; +import { Children } from "../component/Children"; +import type { ComponentProps } from "@/lib/component/Component"; -export interface BoxProps extends Omit { - onChange: ComponentChangeHandler; -} +interface BoxState extends ComponentState {} + +interface BoxProps extends ComponentProps, BoxState {} export function Box({ id, style, children, onChange }: BoxProps) { return ( - + ); } diff --git a/chartlets.js/src/lib/components/Button.tsx b/chartlets.js/src/lib/components/Button.tsx index f444641e..2f5f40a4 100644 --- a/chartlets.js/src/lib/components/Button.tsx +++ b/chartlets.js/src/lib/components/Button.tsx @@ -1,14 +1,17 @@ import { type MouseEvent } from "react"; import MuiButton from "@mui/material/Button"; -import { type ButtonState } from "@/lib/types/state/component"; -import { type ComponentChangeHandler } from "@/lib/types/state/event"; +import { type ComponentState } from "@/lib/types/state/component"; +import type { ComponentProps } from "@/lib/component/Component"; -export interface ButtonProps extends Omit { - onChange: ComponentChangeHandler; +interface ButtonState extends ComponentState { + text?: string; } +interface ButtonProps extends ComponentProps, ButtonState {} + export function Button({ + type, id, name, style, @@ -19,7 +22,7 @@ export function Button({ const handleClick = (_event: MouseEvent) => { if (id) { onChange({ - componentType: "Button", + componentType: type, id: id, property: "clicked", value: true, diff --git a/chartlets.js/src/lib/components/Checkbox.tsx b/chartlets.js/src/lib/components/Checkbox.tsx index 1f048277..140ef8fb 100644 --- a/chartlets.js/src/lib/components/Checkbox.tsx +++ b/chartlets.js/src/lib/components/Checkbox.tsx @@ -3,14 +3,18 @@ import MuiCheckbox from "@mui/material/Checkbox"; import MuiFormControl from "@mui/material/FormControl"; import MuiFormControlLabel from "@mui/material/FormControlLabel"; -import { type CheckboxState } from "@/lib/types/state/component"; -import { type ComponentChangeHandler } from "@/lib/types/state/event"; +import { type ComponentState } from "@/lib/types/state/component"; +import type { ComponentProps } from "@/lib/component/Component"; -export interface CheckboxProps extends Omit { - onChange: ComponentChangeHandler; +interface CheckboxState extends ComponentState { + label?: string; + value?: boolean | undefined; } +interface CheckboxProps extends ComponentProps, CheckboxState {} + export function Checkbox({ + type, id, name, value, @@ -22,7 +26,7 @@ export function Checkbox({ const handleChange = (event: ChangeEvent) => { if (id) { return onChange({ - componentType: "Checkbox", + componentType: type, id: id, property: "value", value: event.currentTarget.checked, diff --git a/chartlets.js/src/lib/components/Component.tsx b/chartlets.js/src/lib/components/Component.tsx deleted file mode 100644 index be06837a..00000000 --- a/chartlets.js/src/lib/components/Component.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { type ComponentState } from "@/lib/types/state/component"; -import { type ComponentChangeHandler } from "@/lib/types/state/event"; -import { Button, type ButtonProps } from "./Button"; -import { Box, type BoxProps } from "./Box"; -import { Checkbox, type CheckboxProps } from "./Checkbox"; -import { Select, type SelectProps } from "./Select"; -import { Plot, type PlotProps } from "./Plot"; -import { Typography, type TypographyProps } from "@/lib/components/Typography"; - -export interface ComponentProps extends ComponentState { - onChange: ComponentChangeHandler; -} - -export function Component({ type, ...props }: ComponentProps) { - // TODO: allow for registering children via their types - // and make following code generic. - // - // const DashiComp = Registry.getComponent(link); - // return return ; - // - if (type === "Plot") { - return ; - } else if (type === "Select") { - return