diff --git a/dashi/package-lock.json b/dashi/package-lock.json index 311b7cce..86867941 100644 --- a/dashi/package-lock.json +++ b/dashi/package-lock.json @@ -1,12 +1,12 @@ { "name": "dashipopashi", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dashipopashi", - "version": "0.0.7", + "version": "0.0.8", "license": "MIT", "dependencies": { "@emotion/react": "^11.13.3", diff --git a/dashi/package.json b/dashi/package.json index e7a1ea15..265c7ff8 100644 --- a/dashi/package.json +++ b/dashi/package.json @@ -1,6 +1,6 @@ { "name": "dashipopashi", - "version": "0.0.7", + "version": "0.0.8", "description": "An experimental library for integrating interactive charts into existing JavaScript applications.", "type": "module", "files": [ diff --git a/dashi/src/demo/actions/hidePanel.ts b/dashi/src/demo/actions/hidePanel.ts index ffb11113..4dd33c69 100644 --- a/dashi/src/demo/actions/hidePanel.ts +++ b/dashi/src/demo/actions/hidePanel.ts @@ -1,6 +1,8 @@ -import { updateContributionState } from "@/lib"; +import { updateContributionContainer } from "@/lib"; import type { PanelState } from "@/demo/types"; export function hidePanel(panelIndex: number) { - updateContributionState("panels", panelIndex, { visible: false }); + updateContributionContainer("panels", panelIndex, { + visible: false, + }); } diff --git a/dashi/src/demo/actions/showPanel.ts b/dashi/src/demo/actions/showPanel.ts index 39190ce2..5a61bb6b 100644 --- a/dashi/src/demo/actions/showPanel.ts +++ b/dashi/src/demo/actions/showPanel.ts @@ -1,6 +1,8 @@ -import { updateContributionState } from "@/lib"; +import { updateContributionContainer } from "@/lib"; import type { PanelState } from "@/demo/types"; export function showPanel(panelIndex: number) { - updateContributionState("panels", panelIndex, { visible: true }); + updateContributionContainer("panels", panelIndex, { + visible: true, + }); } diff --git a/dashi/src/demo/components/Panel.tsx b/dashi/src/demo/components/Panel.tsx index 7e85d6eb..d2a286fa 100644 --- a/dashi/src/demo/components/Panel.tsx +++ b/dashi/src/demo/components/Panel.tsx @@ -1,11 +1,7 @@ import type { CSSProperties, ReactElement } from "react"; import CircularProgress from "@mui/material/CircularProgress"; - -import { - type ComponentChangeHandler, - type ContributionState, - Component, -} from "@/lib"; +import { Component } from "@/lib"; +import type { ComponentState, ComponentChangeHandler } from "@/lib"; import type { PanelState } from "@/demo/types"; const panelContainerStyle: CSSProperties = { @@ -32,39 +28,43 @@ const panelContentStyle: CSSProperties = { padding: 2, }; -interface PanelProps extends ContributionState { +interface PanelProps extends PanelState { + componentProps?: ComponentState; + componentStatus?: string; + componentError?: { message: string }; onChange: ComponentChangeHandler; } function Panel({ - name, - state, - component, - componentResult, + title, + visible, + componentProps, + componentStatus, + componentError, onChange, }: PanelProps) { - if (!state.visible) { + if (!visible) { return null; } let panelElement: ReactElement | null = null; - if (component) { - panelElement = ; - } else if (componentResult.error) { + if (componentProps) { + panelElement = ; + } else if (componentError) { panelElement = ( - Error loading {name}: {componentResult.error.message} + Error loading panel {title}: {componentError.message} ); - } else if (componentResult.status === "pending") { + } else if (componentStatus === "pending") { panelElement = ( - Loading {name}... + Loading {title}... ); } return (
-
{state.title}
+
{title}
{panelElement}
); diff --git a/dashi/src/demo/components/PanelsControl.tsx b/dashi/src/demo/components/PanelsControl.tsx index 9214b7de..ac13d783 100644 --- a/dashi/src/demo/components/PanelsControl.tsx +++ b/dashi/src/demo/components/PanelsControl.tsx @@ -17,15 +17,16 @@ function PanelsControl() { {panelStates.map((panelState, panelIndex) => { const id = `panels.${panelIndex}`; + const { title, visible } = panelState.container; return ( { if (e.currentTarget.checked) { diff --git a/dashi/src/demo/components/PanelsRow.tsx b/dashi/src/demo/components/PanelsRow.tsx index cf0bd986..8a5a5b43 100644 --- a/dashi/src/demo/components/PanelsRow.tsx +++ b/dashi/src/demo/components/PanelsRow.tsx @@ -1,5 +1,3 @@ -import type { JSX } from "react"; - import { type ComponentChangeEvent, handleComponentChange } from "@/lib"; import { usePanelStates } from "@/demo/hooks"; import Panel from "./Panel"; @@ -17,23 +15,21 @@ function PanelsRow() { ) => { handleComponentChange("panels", panelIndex, panelEvent); }; - const visiblePanels: JSX.Element[] = []; - panelStates.forEach((panelState, panelIndex) => { - if (panelState.state.visible) { - visiblePanels.push( - handlePanelChange(panelIndex, e)} - />, - ); - } + const panels = panelStates.map((panelState, panelIndex) => { + const { container, component, componentResult } = panelState; + return ( + handlePanelChange(panelIndex, e)} + /> + ); }); - const panelElements = <>{visiblePanels}; return ( -
- {panelElements} -
+
{panels}
); } diff --git a/dashi/src/lib/actions/configureFramework.ts b/dashi/src/lib/actions/configureFramework.ts index 67123138..ce0dc3e2 100644 --- a/dashi/src/lib/actions/configureFramework.ts +++ b/dashi/src/lib/actions/configureFramework.ts @@ -1,6 +1,6 @@ import { store } from "@/lib/store"; import type { FrameworkOptions } from "@/lib/types/state/store"; -import { configureLogging } from "@/lib/utils/configureLogging"; +import { configureLogging } from "@/lib/actions/helpers/configureLogging"; import { handleHostStoreChange } from "./handleHostStoreChange"; export function configureFramework( diff --git a/dashi/src/lib/actions/handleComponentChange.ts b/dashi/src/lib/actions/handleComponentChange.ts index bb073b56..f215e378 100644 --- a/dashi/src/lib/actions/handleComponentChange.ts +++ b/dashi/src/lib/actions/handleComponentChange.ts @@ -17,7 +17,7 @@ export function handleComponentChange( contribIndex, stateChanges: [ { - kind: "Component", + link: "component", id: changeEvent.componentId, property: changeEvent.propertyName, value: changeEvent.propertyValue, @@ -57,7 +57,8 @@ function getCallbackRequests( const inputs = callback.inputs; const inputIndex = inputs.findIndex( (input) => - (!input.kind || input.kind === "Component") && + !input.noTrigger && + (!input.link || input.link === "component") && input.id === changeEvent.componentId && input.property === changeEvent.propertyName, ); diff --git a/dashi/src/lib/actions/handleHostStoreChange.ts b/dashi/src/lib/actions/handleHostStoreChange.ts index 29e7a437..110f3ae1 100644 --- a/dashi/src/lib/actions/handleHostStoreChange.ts +++ b/dashi/src/lib/actions/handleHostStoreChange.ts @@ -5,9 +5,9 @@ import type { CallbackRef, CallbackRequest, ContribRef, - Input, InputRef, } from "@/lib/types/model/callback"; +import type { Input } from "@/lib/types/model/channel"; import { getInputValues } from "@/lib/actions/helpers/getInputValues"; import { getValue, type PropertyPath } from "@/lib/utils/getValue"; import { invokeCallbacks } from "@/lib/actions/helpers/invokeCallbacks"; @@ -59,6 +59,9 @@ function getCallbackRequests( const getHostStorePropertyRefs = memoizeOne(_getHostStorePropertyRefs); +/** + * Get the static list of host state property references for all contributions. + */ function _getHostStorePropertyRefs(): PropertyRef[] { const { contributionsRecord } = store.getState(); const propertyRefs: PropertyRef[] = []; @@ -68,13 +71,13 @@ function _getHostStorePropertyRefs(): PropertyRef[] { (contribution.callbacks || []).forEach( (callback, callbackIndex) => (callback.inputs || []).forEach((input, inputIndex) => { - if (input.kind === "AppState") { + if (!input.noTrigger && input.link === "app") { propertyRefs.push({ contribPoint, contribIndex, callbackIndex, inputIndex, - propertyPath: input.property.split("."), + propertyPath: input.property!.split("."), }); } }), diff --git a/dashi/src/lib/actions/helpers/applyStateChangeRequests.test.ts b/dashi/src/lib/actions/helpers/applyStateChangeRequests.test.ts index ee6c822d..972721d3 100644 --- a/dashi/src/lib/actions/helpers/applyStateChangeRequests.test.ts +++ b/dashi/src/lib/actions/helpers/applyStateChangeRequests.test.ts @@ -35,7 +35,7 @@ describe("Test that applyContributionChangeRequests()", () => { name: "", extension: "", componentResult: { status: "ok" }, - state: { visible: true }, + container: { visible: true }, component: componentTree, }, ], @@ -46,7 +46,7 @@ describe("Test that applyContributionChangeRequests()", () => { contribIndex: 0, stateChanges: [ { - kind: "Component", + link: "component", id: "dd1", property: "value", value: 14, @@ -59,7 +59,7 @@ describe("Test that applyContributionChangeRequests()", () => { contribIndex: 0, stateChanges: [ { - kind: "Component", + link: "component", id: "dd1", property: "value", value: 13, @@ -88,7 +88,7 @@ describe("Test that applyContributionChangeRequests()", () => { describe("Test that applyComponentStateChange()", () => { it("changes state if values are different", () => { const newState = applyComponentStateChange(componentTree, { - kind: "Component", + link: "component", id: "cb1", property: "value", value: false, @@ -99,7 +99,7 @@ describe("Test that applyComponentStateChange()", () => { it("doesn't change the state if value stays the same", () => { const newState = applyComponentStateChange(componentTree, { - kind: "Component", + link: "component", id: "cb1", property: "value", value: true, diff --git a/dashi/src/lib/actions/helpers/applyStateChangeRequests.ts b/dashi/src/lib/actions/helpers/applyStateChangeRequests.ts index f0b94884..f17d6120 100644 --- a/dashi/src/lib/actions/helpers/applyStateChangeRequests.ts +++ b/dashi/src/lib/actions/helpers/applyStateChangeRequests.ts @@ -10,7 +10,7 @@ import type { import type { ContribPoint } from "@/lib/types/model/extension"; import type { ContributionState } from "@/lib"; import { updateArray } from "@/lib/utils/updateArray"; -import { isContainerState } from "@/lib/utils/isContainerState"; +import { isContainerState } from "@/lib/actions/helpers/isContainerState"; export function applyStateChangeRequests( stateChangeRequests: StateChangeRequest[], @@ -37,8 +37,9 @@ function applyHostStateChanges(stateChangeRequests: StateChangeRequest[]) { stateChangeRequests.forEach((stateChangeRequest) => { hostState = applyStateChanges( hostState, - stateChangeRequest.stateChanges, - "AppState", + stateChangeRequest.stateChanges.filter( + (stateChange) => stateChange.link === "app", + ), ); }); if (hostState !== hostStateOld) { @@ -53,13 +54,9 @@ function applyComponentStateChanges( ) { let component = componentOld; if (component) { - stateChanges - .filter( - (stateChange) => !stateChange.kind || stateChange.kind === "Component", - ) - .forEach((stateChange) => { - component = applyComponentStateChange(component!, stateChange); - }); + stateChanges.forEach((stateChange) => { + component = applyComponentStateChange(component!, stateChange); + }); } return component; } @@ -72,17 +69,19 @@ export function applyContributionChangeRequests( stateChangeRequests.forEach( ({ contribPoint, contribIndex, stateChanges }) => { const contribution = contributionsRecord[contribPoint][contribIndex]; - const state = applyStateChanges( - contribution.state, - stateChanges, - "State", + const container = applyStateChanges( + contribution.container, + stateChanges.filter((stateChange) => stateChange.link === "container"), ); const component = applyComponentStateChanges( contribution.component, - stateChanges, + stateChanges.filter( + (stateChange) => + !stateChange.link || stateChange.link === "component", + ), ); if ( - state !== contribution.state || + container !== contribution.container || component !== contribution.component ) { contributionsRecord = { @@ -90,7 +89,7 @@ export function applyContributionChangeRequests( [contribPoint]: updateArray( contributionsRecord[contribPoint], contribIndex, - { ...contribution, state, component }, + { ...contribution, container, component }, ), }; } @@ -139,14 +138,12 @@ export function applyComponentStateChange( export function applyStateChanges( state: S | undefined, stateChanges: StateChange[], - kind: "State" | "AppState", ): S | undefined { stateChanges.forEach((stateChange) => { if ( - stateChange.kind === kind && - (!state || - (state as unknown as Record)[stateChange.property] !== - stateChange.value) + !state || + (state as unknown as Record)[stateChange.property] !== + stateChange.value ) { state = { ...state, [stateChange.property]: stateChange.value } as S; } diff --git a/dashi/src/lib/utils/configureLogging.ts b/dashi/src/lib/actions/helpers/configureLogging.ts similarity index 100% rename from dashi/src/lib/utils/configureLogging.ts rename to dashi/src/lib/actions/helpers/configureLogging.ts diff --git a/dashi/src/lib/actions/helpers/getInputValues.test.ts b/dashi/src/lib/actions/helpers/getInputValues.test.ts index ac063ed2..ce83e2e1 100644 --- a/dashi/src/lib/actions/helpers/getInputValues.test.ts +++ b/dashi/src/lib/actions/helpers/getInputValues.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import type { ComponentState, PlotState } from "@/lib/types/state/component"; import { - getComponentStateValue, + getInputValueFromComponent, getInputValueFromState, } from "./getInputValues"; @@ -23,11 +23,11 @@ const componentState: ComponentState = { value: 14, }; -describe("Test that getComponentStateValue()", () => { +describe("Test that getInputValueFromComponent()", () => { it("works on 1st level", () => { expect( - getComponentStateValue(componentState, { - kind: "Component", + getInputValueFromComponent(componentState, { + link: "component", id: "b1", property: "value", }), @@ -36,8 +36,8 @@ describe("Test that getComponentStateValue()", () => { it("works on 2nd level", () => { expect( - getComponentStateValue(componentState, { - kind: "Component", + getInputValueFromComponent(componentState, { + link: "component", id: "p1", property: "chart", }), @@ -46,16 +46,16 @@ describe("Test that getComponentStateValue()", () => { it("works on 3rd level", () => { expect( - getComponentStateValue(componentState, { - kind: "Component", + getInputValueFromComponent(componentState, { + link: "component", id: "cb1", property: "value", }), ).toEqual(true); expect( - getComponentStateValue(componentState, { - kind: "Component", + getInputValueFromComponent(componentState, { + link: "component", id: "dd1", property: "value", }), @@ -67,21 +67,27 @@ describe("Test that getInputValueFromState()", () => { it("works with input.id and input.property", () => { const state = { x: { y: 26 } }; expect( - getInputValueFromState({ kind: "State", id: "x", property: "y" }, state), + getInputValueFromState( + { link: "component", id: "x", property: "y" }, + state, + ), ).toEqual(26); }); it("works with arrays indexes", () => { const state = { x: [4, 5, 6] }; expect( - getInputValueFromState({ kind: "State", id: "x", property: "1" }, state), + getInputValueFromState( + { link: "component", id: "x", property: "1" }, + state, + ), ).toEqual(5); }); it("works without input.id", () => { const state = { x: [4, 5, 6] }; expect( - getInputValueFromState({ kind: "State", property: "x" }, state), + getInputValueFromState({ link: "container", property: "x" }, state), ).toEqual([4, 5, 6]); }); }); diff --git a/dashi/src/lib/actions/helpers/getInputValues.ts b/dashi/src/lib/actions/helpers/getInputValues.ts index 80257593..77d1b5c0 100644 --- a/dashi/src/lib/actions/helpers/getInputValues.ts +++ b/dashi/src/lib/actions/helpers/getInputValues.ts @@ -1,8 +1,8 @@ -import type { Input } from "@/lib/types/model/callback"; +import type { Input } from "@/lib/types/model/channel"; import type { ContributionState } from "@/lib/types/state/contribution"; import type { ComponentState } from "@/lib/types/state/component"; import { isSubscriptable } from "@/lib/utils/isSubscriptable"; -import { isContainerState } from "@/lib/utils/isContainerState"; +import { isContainerState } from "@/lib/actions/helpers/isContainerState"; export function getInputValues( inputs: Input[], @@ -22,16 +22,16 @@ export function getInputValue( hostState?: S | undefined, ): unknown { let inputValue: unknown = undefined; - const inputKind = input.kind || "Component"; - if (inputKind === "Component" && contributionState.component) { + const dataSource = input.link || "component"; + if (dataSource === "component" && contributionState.component) { // Return value of a property of some component in the tree - inputValue = getComponentStateValue(contributionState.component, input); - } else if (inputKind === "State" && contributionState.state) { - inputValue = getInputValueFromState(input, contributionState.state); - } else if (inputKind === "AppState" && hostState) { + inputValue = getInputValueFromComponent(contributionState.component, input); + } else if (dataSource === "container" && contributionState.container) { + inputValue = getInputValueFromState(input, hostState); + } else if (dataSource === "app" && hostState) { inputValue = getInputValueFromState(input, hostState); } else { - console.warn(`input of unknown kind:`, input); + console.warn(`input with unknown data source:`, input); } if (inputValue === undefined || inputValue === noValue) { // We use null, because undefined is not JSON-serializable. @@ -42,7 +42,7 @@ export function getInputValue( } // we export for testing only -export function getComponentStateValue( +export function getInputValueFromComponent( componentState: ComponentState, input: Input, ): unknown { @@ -53,7 +53,7 @@ export function getComponentStateValue( } else if (isContainerState(componentState)) { for (let i = 0; i < componentState.components.length; i++) { const item = componentState.components[i]; - const itemValue = getComponentStateValue(item, input); + const itemValue = getInputValueFromComponent(item, input); if (itemValue !== noValue) { return itemValue; } diff --git a/dashi/src/lib/actions/helpers/invokeCallbacks.ts b/dashi/src/lib/actions/helpers/invokeCallbacks.ts index 83082a86..e528ad2a 100644 --- a/dashi/src/lib/actions/helpers/invokeCallbacks.ts +++ b/dashi/src/lib/actions/helpers/invokeCallbacks.ts @@ -6,7 +6,10 @@ import { applyStateChangeRequests } from "@/lib/actions/helpers/applyStateChange export function invokeCallbacks(callbackRequests: CallbackRequest[]) { const { configuration } = store.getState(); - console.debug("invokeCallbacks -->", callbackRequests); + const invocationId = getInvocationId(); + if (import.meta.env.DEV) { + console.debug(`invokeCallbacks (${invocationId})-->`, callbackRequests); + } if (callbackRequests.length) { fetchApiResult( fetchStateChangeRequests, @@ -14,7 +17,12 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) { configuration.api, ).then((changeRequestsResult) => { if (changeRequestsResult.data) { - console.debug("invokeCallbacks <--", changeRequestsResult.data); + if (import.meta.env.DEV) { + console.debug( + `invokeCallbacks <--(${invocationId})`, + changeRequestsResult.data, + ); + } applyStateChangeRequests(changeRequestsResult.data); } else { console.error( @@ -27,3 +35,9 @@ export function invokeCallbacks(callbackRequests: CallbackRequest[]) { }); } } + +let invocationCounter = 0; + +function getInvocationId() { + return invocationCounter++; +} diff --git a/dashi/src/lib/utils/isContainerState.ts b/dashi/src/lib/actions/helpers/isContainerState.ts similarity index 54% rename from dashi/src/lib/utils/isContainerState.ts rename to dashi/src/lib/actions/helpers/isContainerState.ts index 01a5930d..8d2a1d1b 100644 --- a/dashi/src/lib/utils/isContainerState.ts +++ b/dashi/src/lib/actions/helpers/isContainerState.ts @@ -4,7 +4,7 @@ import { } from "@/lib/types/state/component"; export function isContainerState( - componentModel: ComponentState, -): componentModel is ContainerState { - return !!componentModel.components; + component: ComponentState, +): component is ContainerState { + return !!component.components; } diff --git a/dashi/src/lib/actions/initializeContributions.ts b/dashi/src/lib/actions/initializeContributions.ts index 4bd0516e..64109047 100644 --- a/dashi/src/lib/actions/initializeContributions.ts +++ b/dashi/src/lib/actions/initializeContributions.ts @@ -49,7 +49,7 @@ function newContributionState( ): ContributionState { return { ...rawContribution, - state: { ...rawContribution.initialState }, + container: { ...rawContribution.initialState }, componentResult: {}, }; } diff --git a/dashi/src/lib/actions/updateContributionState.ts b/dashi/src/lib/actions/updateContributionContainer.ts similarity index 87% rename from dashi/src/lib/actions/updateContributionState.ts rename to dashi/src/lib/actions/updateContributionContainer.ts index 742c8b0f..dfc11169 100644 --- a/dashi/src/lib/actions/updateContributionState.ts +++ b/dashi/src/lib/actions/updateContributionContainer.ts @@ -6,31 +6,30 @@ import { updateArray } from "@/lib/utils/updateArray"; import type { ContribPoint } from "@/lib/types/model/extension"; import type { ContributionState } from "@/lib/types/state/contribution"; -export function updateContributionState( +export function updateContributionContainer( contribPoint: ContribPoint, contribIndex: number, - contribState: Partial, + container: Partial, requireComponent: boolean = true, ) { const { configuration, contributionsRecord } = store.getState(); const contributionStates = contributionsRecord[contribPoint]; const contributionState = contributionStates[contribIndex]; - if (contributionState.state === contribState) { + if (contributionState.container === container) { return; // nothing to do } const componentStatus = contributionState.componentResult.status; if (!requireComponent || componentStatus) { _updateContributionState(contribPoint, contribIndex, { - state: contribState, + container, }); } else if (!componentStatus) { // No status yet, so we must load the component _updateContributionState(contribPoint, contribIndex, { - state: contribState, + container, componentResult: { status: "pending" }, }); const inputValues = getLayoutInputValues(contribPoint, contribIndex); - console.log("inputValues:", inputValues); fetchApiResult( fetchInitialComponentState, contribPoint, @@ -70,10 +69,10 @@ function _updateContributionState( const { contributionsRecord } = store.getState(); const contribStates = contributionsRecord[contribPoint]!; const contribStateOld = contribStates[contribIndex]; - const contribStateNew = contribState.state + const contribStateNew = contribState.container ? { ...contribState, - state: { ...contribStateOld.state, ...contribState.state }, + container: { ...contribStateOld.container, ...contribState.container }, } : contribState; store.setState({ diff --git a/dashi/src/lib/components/Component.tsx b/dashi/src/lib/components/Component.tsx index f8f21cd1..dd05ee17 100644 --- a/dashi/src/lib/components/Component.tsx +++ b/dashi/src/lib/components/Component.tsx @@ -15,7 +15,7 @@ export function Component({ type, ...props }: ComponentProps) { // TODO: allow for registering components via their types // and make following code generic. // - // const DashiComp = Registry.getComponent(type); + // const DashiComp = Registry.getComponent(link); // return return ; // if (type === "Plot") { diff --git a/dashi/src/lib/index.ts b/dashi/src/lib/index.ts index e09a1205..2a3f76a9 100644 --- a/dashi/src/lib/index.ts +++ b/dashi/src/lib/index.ts @@ -1,6 +1,7 @@ // Types export { type Contribution } from "@/lib/types/model/contribution"; export { type ContributionState } from "@/lib/types/state/contribution"; +export { type ComponentState } from "@/lib/types/state/component"; export { type ComponentChangeEvent, type ComponentChangeHandler, @@ -9,7 +10,7 @@ export { export { initializeContributions } from "@/lib/actions/initializeContributions"; export { configureFramework } from "@/lib/actions/configureFramework"; export { handleComponentChange } from "@/lib/actions/handleComponentChange"; -export { updateContributionState } from "@/lib/actions/updateContributionState"; +export { updateContributionContainer } from "@/lib/actions/updateContributionContainer"; // React Components export { Component } from "@/lib/components/Component"; // React Hooks @@ -19,5 +20,3 @@ export { useContributionsResult, useContributionsRecord, } from "@/lib/hooks"; -// Utilities -export { configureLogging } from "@/lib/utils/configureLogging"; diff --git a/dashi/src/lib/types/model/callback.ts b/dashi/src/lib/types/model/callback.ts index cd4fae19..d8f94219 100644 --- a/dashi/src/lib/types/model/callback.ts +++ b/dashi/src/lib/types/model/callback.ts @@ -1,3 +1,5 @@ +import type { Input, Output } from "@/lib/types/model/channel"; + export interface Callback { function: CbFunction; inputs?: Input[]; @@ -30,32 +32,6 @@ export interface CbParameter { default?: unknown; } -export type InputOutputKind = "AppState" | "State" | "Component"; - -export interface InputOutput { - /** The kind of input or output. */ - kind: InputOutputKind; - /** - * The identifier of a subcomponent. - * `id` is not needed if kind == "AppState" | "State". - */ - id?: string; - - // TODO: we must allow `property` to be a constant - // expression of the form: name {"." name | index}. - // Then we get the normalized form - // property: string[]; - - /** - * The property of an object or array index. - */ - property: string; -} - -export interface Input extends InputOutput {} - -export interface Output extends InputOutput {} - /** * A reference to a specific contribution. */ diff --git a/dashi/src/lib/types/model/channel.ts b/dashi/src/lib/types/model/channel.ts new file mode 100644 index 00000000..145f2de4 --- /dev/null +++ b/dashi/src/lib/types/model/channel.ts @@ -0,0 +1,33 @@ +export type Link = "component" | "container" | "app"; + +/** + * Base for `Input` and `Output`. + */ +export interface Channel { + /** + * The link provides the source for inputs and that target for outputs. + */ + link: Link; + + /** + * The identifier of a subcomponent. + * `id` is not needed if link == "AppInput" | "AppOutput". + */ + id?: string; + + // TODO: we must allow `property` to be a constant + // expression of the form: name {"." name | index}. + // Then we get the normalized form + // property: string[]; + + /** + * The property of an object or array index. + */ + property: string; +} + +export interface Input extends Channel { + noTrigger?: boolean; +} + +export interface Output extends Channel {} diff --git a/dashi/src/lib/types/state/contribution.ts b/dashi/src/lib/types/state/contribution.ts index aaafd46e..25237e89 100644 --- a/dashi/src/lib/types/state/contribution.ts +++ b/dashi/src/lib/types/state/contribution.ts @@ -5,9 +5,9 @@ import type { ComponentState } from "./component"; export interface ContributionState extends Contribution { /** - * Contribution-private state properties. + * State that is used by the container of this contribution. */ - state: S; + container: S; /** * The result of loading the initial component tree. */ diff --git a/dashi/src/lib/types/state/store.ts b/dashi/src/lib/types/state/store.ts index 688b1995..038cc5a3 100644 --- a/dashi/src/lib/types/state/store.ts +++ b/dashi/src/lib/types/state/store.ts @@ -6,7 +6,7 @@ import type { import type { ApiResult } from "@/lib/utils/fetchApiResult"; import type { ContributionState } from "@/lib/types/state/contribution"; import type { ApiOptions } from "@/lib/api"; -import type { LoggingOptions } from "@/lib/utils/configureLogging"; +import type { LoggingOptions } from "@/lib/actions/helpers/configureLogging"; import type { StoreApi } from "zustand"; export interface FrameworkOptions { diff --git a/dashi/tsconfig.json b/dashi/tsconfig.json index c344941d..34a17a3a 100644 --- a/dashi/tsconfig.json +++ b/dashi/tsconfig.json @@ -17,6 +17,7 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "types": ["vite/client"], /* Linting */ "strict": true, diff --git a/dashipy/dashipy/__init__.py b/dashipy/dashipy/__init__.py index 8109e2c3..8623ca89 100644 --- a/dashipy/dashipy/__init__.py +++ b/dashipy/dashipy/__init__.py @@ -1,6 +1,7 @@ from .callback import Callback -from .callback import Input -from .callback import Output +from .channel import Input +from .channel import Output +from .channel import State from .component import Component from .container import Container from .extension import Contribution diff --git a/dashipy/dashipy/callback.py b/dashipy/dashipy/callback.py index e8a103c8..de56f1e5 100644 --- a/dashipy/dashipy/callback.py +++ b/dashipy/dashipy/callback.py @@ -1,51 +1,12 @@ import inspect import types -from abc import ABC -from typing import Callable, Any, Literal +from typing import Any, Callable - -ComponentKind = Literal["Component"] -AppStateKind = Literal["AppState"] -StateKind = Literal["State"] -InputOutputKind = ComponentKind | StateKind | AppStateKind - - -class InputOutput(ABC): - # noinspection PyShadowingBuiltins - def __init__( - self, - id: str | None = None, - property: str | None = None, - kind: InputOutputKind | None = None, - ): - kind = "Component" if kind is None else kind - assert kind in ("AppState", "State", "Component") - if kind == "Component": - assert id is not None and isinstance(id, str) and id != "" - property = "value" if property is None else property - assert isinstance(property, str) and property != "" - else: - assert id is None or (isinstance(id, str) and id != "") - assert property is None or (isinstance(property, str) and property != "") - - self.kind = kind - self.id = id - self.property = property - - def to_dict(self) -> dict[str, Any]: - return { - k: v - for k, v in self.__dict__.items() - if not k.startswith("_") and v is not None - } - - -class Input(InputOutput): - """Callback input.""" - - -class Output(InputOutput): - """Callback output.""" +from dashipy.channel import ( + Input, + Output, + State, +) class Callback: @@ -81,18 +42,18 @@ def from_decorator( f" context parameter" ) - inputs: list[Input] = [] + inputs: list[Input | State] = [] outputs: list[Output] = [] for arg in decorator_args: - if isinstance(arg, Input): + if isinstance(arg, (Input, State)): inputs.append(arg) elif outputs_allowed and isinstance(arg, Output): outputs.append(arg) elif outputs_allowed: raise TypeError( f"arguments for decorator {decorator_name!r}" - f" must be of type Input or Output," - f" but got {arg.__class__.__name__!r}" + f" must be of type Input, State, Output, AppInput," + f" or AppOutput, but got {arg.__class__.__name__!r}" ) else: raise TypeError( @@ -118,7 +79,7 @@ def from_decorator( def __init__( self, function: Callable, - inputs: list[Input], + inputs: list[Input | State], outputs: list[Output], signature: inspect.Signature | None = None, ): diff --git a/dashipy/dashipy/channel.py b/dashipy/dashipy/channel.py new file mode 100644 index 00000000..cc5edf33 --- /dev/null +++ b/dashipy/dashipy/channel.py @@ -0,0 +1,121 @@ +from abc import ABC +from typing import Any, Literal + +from .util.assertions import assert_is_instance_of +from .util.assertions import assert_is_none +from .util.assertions import assert_is_one_of + + +Link = Literal["component"] | Literal["container"] | Literal["app"] + + +class Channel(ABC): + """Base class for `Input`, `State`, and `Output`. + Instances are used as argument passed to + the `layout` and `callback` decorators. + """ + + # noinspection PyShadowingBuiltins + def __init__( + self, + link: Link | None = None, + id: str | None = None, + property: str | None = None, + ): + self.link = link + self.id = id + self.property = property + + def to_dict(self) -> dict[str, Any]: + d = { + k: v + for k, v in self.__dict__.items() + if not k.startswith("_") and v is not None + } + if self.no_trigger: + d |= dict(noTrigger=True) + return d + + @property + def no_trigger(self): + return isinstance(self, State) + + +class Input(Channel): + """An input value read from component state. + A component state change may trigger callback invocation. + """ + + # noinspection PyShadowingBuiltins + def __init__( + self, + id: str | None = None, + property: str | None = None, + source: Link | None = None, + ): + link, id, property = _validate_input_params(source, id, property) + super().__init__(link=link, id=id, property=property) + + +class State(Input): + """An input value read from component state. + Does not trigger callback invocation. + """ + + # noinspection PyShadowingBuiltins + def __init__( + self, + id: str | None = None, + property: str | None = None, + source: Link | None = None, + ): + super().__init__(id=id, property=property, source=source) + + +class Output(Channel): + """Callback output.""" + + # noinspection PyShadowingBuiltins + def __init__( + self, + id: str | None = None, + property: str | None = None, + target: Link | None = None, + ): + target, id, property = _validate_output_params(target, id, property) + super().__init__(link=target, id=id, property=property) + + +NoneType = type(None) + + +# noinspection PyShadowingBuiltins +def _validate_input_params( + source: Link | None, id: str | None, property: str | None +) -> tuple[Link, str | None, str | None]: + return _validate_params("source", source, id, property) + + +# noinspection PyShadowingBuiltins +def _validate_output_params( + target: Link | None, id: str | None, property: str | None +) -> tuple[Link, str | None, str | None]: + return _validate_params("target", target, id, property) + + +# noinspection PyShadowingBuiltins +def _validate_params( + link_name: str, link: Link | None, id: str | None, property: str | None +) -> tuple[Link, str | None, str | None]: + assert_is_one_of(link_name, link, ("component", "container", "app", None)) + if not link or link == "component": + assert_is_instance_of("id", id, (str, NoneType)) + assert_is_instance_of("property", id, (str, NoneType)) + link = link or "component" + if property is None and id is not None: + property = "value" + else: + assert_is_none("id", id) + assert_is_instance_of("property", property, str) + # noinspection PyTypeChecker + return link, id, property diff --git a/dashipy/dashipy/contribution.py b/dashipy/dashipy/contribution.py index 79a797bd..43e253ec 100644 --- a/dashipy/dashipy/contribution.py +++ b/dashipy/dashipy/contribution.py @@ -1,7 +1,8 @@ from typing import Any, Callable from abc import ABC -from .callback import Callback, Output, Input +from .callback import Callback +from .channel import Channel class Contribution(ABC): @@ -36,7 +37,7 @@ def decorator(function: Callable) -> Callable: return decorator - def callback(self, *args: Input | Output) -> Callable[[Callable], Callable]: + def callback(self, *args: Channel) -> Callable[[Callable], Callable]: """Decorator.""" def decorator(function: Callable) -> Callable: diff --git a/dashipy/tests/contribs/__init__.py b/dashipy/dashipy/util/__init__.py similarity index 100% rename from dashipy/tests/contribs/__init__.py rename to dashipy/dashipy/util/__init__.py diff --git a/dashipy/dashipy/util/assertions.py b/dashipy/dashipy/util/assertions.py new file mode 100644 index 00000000..51d3f434 --- /dev/null +++ b/dashipy/dashipy/util/assertions.py @@ -0,0 +1,20 @@ +from typing import Any, Container, Type + + +def assert_is_one_of(name: str, value: Any, value_set: Container): + if value not in value_set: + raise ValueError( + f"value of {name!r} must be one of {value_set!r}, but was {value!r}" + ) + + +def assert_is_instance_of(name: str, value: Any, type_set: Type | tuple[Type, ...]): + if not isinstance(value, type_set): + raise TypeError( + f"value of {name!r} must be an instance of {type_set!r}, but was {value!r}" + ) + + +def assert_is_none(name: str, value: Any): + if value is not None: + raise TypeError(f"value of {name!r} must be None, but was {value!r}") diff --git a/dashipy/my_extension/my_panel_2.py b/dashipy/my_extension/my_panel_2.py index 7ddffced..2208c813 100644 --- a/dashipy/my_extension/my_panel_2.py +++ b/dashipy/my_extension/my_panel_2.py @@ -10,7 +10,7 @@ panel = Panel(__name__, title="Panel B") -@panel.layout(Input(kind="AppState", property="selectedDatasetId")) +@panel.layout(Input(property="selectedDatasetId", source="app")) def render_panel( ctx: Context, selected_dataset_id: str = "", @@ -51,7 +51,7 @@ def render_panel( @panel.callback( - Input(kind="AppState", property="selectedDatasetId"), + Input(property="selectedDatasetId", source="app"), Input("selected_variable_name"), Output("plot", "chart"), ) @@ -89,7 +89,7 @@ def make_figure( @panel.callback( - Input(kind="AppState", property="selectedDatasetId"), + Input(property="selectedDatasetId", source="app"), Output("selected_variable_name", "options"), Output("selected_variable_name", "value"), ) diff --git a/dashipy/my_extension/my_panel_3.py b/dashipy/my_extension/my_panel_3.py index 40d29a11..708762ef 100644 --- a/dashipy/my_extension/my_panel_3.py +++ b/dashipy/my_extension/my_panel_3.py @@ -1,4 +1,4 @@ -from dashipy import Component, Input, Output +from dashipy import Component, Input, State, Output from dashipy.components import Box, Dropdown, Checkbox, Typography from dashipy.demo.contribs import Panel from dashipy.demo.context import Context @@ -11,7 +11,7 @@ @panel.layout( - Input(kind="AppState", property="selectedDatasetId"), + Input(source="app", property="selectedDatasetId"), ) def render_panel( ctx: Context, @@ -53,9 +53,10 @@ def render_panel( # noinspection PyUnusedLocal @panel.callback( - Input(kind="AppState", property="selectedDatasetId"), + Input(source="app", property="selectedDatasetId"), Input("opaque"), Input("color"), + State("info_text", "text"), Output("info_text", "text"), ) def update_info_text( @@ -63,11 +64,14 @@ def update_info_text( dataset_id: str = "", opaque: bool = False, color: int = 0, + info_text: str = "" ) -> str: opaque = opaque or False color = color if color is not None else 0 return ( f"The dataset is {dataset_id}," f" the color is {COLORS[color][0]} and" - f" it {'is' if opaque else 'is not'} opaque" + f" it {'is' if opaque else 'is not'} opaque." + f" The length of the last info text" + f" was {len(info_text or "")}." ) diff --git a/dashipy/tests/lib/callback_test.py b/dashipy/tests/callback_test.py similarity index 73% rename from dashipy/tests/lib/callback_test.py rename to dashipy/tests/callback_test.py index 3d749e84..981f3dc1 100644 --- a/dashipy/tests/lib/callback_test.py +++ b/dashipy/tests/callback_test.py @@ -5,6 +5,7 @@ from dashipy.callback import Input, Callback, Output +# noinspection PyUnusedLocal def my_callback( ctx, a: int, @@ -59,10 +60,26 @@ def test_to_dict_with_no_outputs(self): "returnType": {"type": "string"}, }, "inputs": [ - {"id": "a", "kind": "Component", "property": "value"}, - {"id": "b", "kind": "Component", "property": "value"}, - {"id": "c", "kind": "Component", "property": "value"}, - {"id": "d", "kind": "Component", "property": "value"}, + { + "link": "component", + "id": "a", + "property": "value", + }, + { + "link": "component", + "id": "b", + "property": "value", + }, + { + "link": "component", + "id": "c", + "property": "value", + }, + { + "link": "component", + "id": "d", + "property": "value", + }, ], }, d, @@ -73,8 +90,8 @@ def test_to_dict_with_two_outputs(self): my_callback_2, [Input("n")], [ - Output(id="select", property="options"), - Output(id="select", property="value"), + Output("select", "options"), + Output("select", "value"), ], ) d = callback.to_dict() @@ -92,10 +109,24 @@ def test_to_dict_with_two_outputs(self): "type": "array", }, }, - "inputs": [{"id": "n", "kind": "Component", "property": "value"}], + "inputs": [ + { + "link": "component", + "id": "n", + "property": "value", + } + ], "outputs": [ - {"id": "select", "kind": "Component", "property": "options"}, - {"id": "select", "kind": "Component", "property": "value"}, + { + "link": "component", + "id": "select", + "property": "options", + }, + { + "link": "component", + "id": "select", + "property": "value", + }, ], }, d, @@ -141,7 +172,9 @@ def test_decorator_args(self): with pytest.raises( TypeError, - match="arguments for decorator 'test' must be of" - " type Input or Output, but got 'int'", + match=( + "arguments for decorator 'test' must be of type Input," + " State, Output, AppInput, or AppOutput, but got 'int'" + ), ): Callback.from_decorator("test", (13,), my_callback, outputs_allowed=True) diff --git a/dashipy/tests/channel_test.py b/dashipy/tests/channel_test.py new file mode 100644 index 00000000..bda2922f --- /dev/null +++ b/dashipy/tests/channel_test.py @@ -0,0 +1,104 @@ +import unittest +from typing import Type + +import pytest + +from dashipy.channel import Channel, Input, State, Output + + +def make_base(): + class ChannelTest(unittest.TestCase): + channel_cls: Type[Channel] + link_name: str + + def test_no_args(self): + obj = self.channel_cls() + self.assertEqual("component", obj.link) + self.assertEqual(None, obj.id) + self.assertEqual(None, obj.property) + + def test_id_given(self): + obj = self.channel_cls("dataset_select") + self.assertEqual("component", obj.link) + self.assertEqual("dataset_select", obj.id) + self.assertEqual("value", obj.property) + + def test_app(self): + obj = self.channel_cls(property="datasetId", **{self.link_name: "app"}) + self.assertEqual("app", obj.link) + self.assertEqual(None, obj.id) + self.assertEqual("datasetId", obj.property) + + def test_container_with_id(self): + with pytest.raises( + TypeError, + match="value of 'id' must be None, but was 'test_id'", + ): + self.channel_cls("test_id", **{self.link_name: "container"}) + + def test_app_no_prop(self): + with pytest.raises( + TypeError, + match=( + "value of 'property' must be an instance" + " of , but was None" + ), + ): + self.channel_cls(**{self.link_name: "app"}) + + def test_wrong_link(self): + with pytest.raises( + ValueError, + match=( + f"value of '{self.link_name}' must be one of" + f" \\('component', 'container', 'app', None\\)," + f" but was 'host'" + ), + ): + self.channel_cls(**{self.link_name: "host"}) + + def test_no_trigger(self): + obj = self.channel_cls() + if isinstance(obj, State): + self.assertTrue(obj.no_trigger) + else: + self.assertFalse(obj.no_trigger) + + def test_to_dict(self): + obj = self.channel_cls("test_id") + if isinstance(obj, State): + self.assertEqual( + { + "link": "component", + "id": "test_id", + "property": "value", + "noTrigger": True, + }, + obj.to_dict(), + ) + else: + self.assertEqual( + { + "link": "component", + "id": "test_id", + "property": "value", + }, + obj.to_dict(), + ) + + return ChannelTest + + +class InputTest(make_base(), unittest.TestCase): + channel_cls = Input + link_name = "source" + + +class StateTest(make_base(), unittest.TestCase): + channel_cls = State + link_name = "source" + + +class OutputTest(make_base(), unittest.TestCase): + channel_cls = Output + link_name = "target" diff --git a/dashipy/tests/lib/component_test.py b/dashipy/tests/component_test.py similarity index 100% rename from dashipy/tests/lib/component_test.py rename to dashipy/tests/component_test.py diff --git a/dashipy/tests/components/box_test.py b/dashipy/tests/components/box_test.py index 8613fd01..170fdca9 100644 --- a/dashipy/tests/components/box_test.py +++ b/dashipy/tests/components/box_test.py @@ -1,9 +1,6 @@ import json import unittest -import plotly.graph_objects as go -from plotly.graph_objs import Layout - from dashipy.components import Box diff --git a/dashipy/tests/components/plot_test.py b/dashipy/tests/components/plot_test.py index 082c2b95..8dd96e15 100644 --- a/dashipy/tests/components/plot_test.py +++ b/dashipy/tests/components/plot_test.py @@ -1,22 +1,7 @@ -import json import unittest -import plotly.graph_objects as go -from plotly.graph_objs import Layout - -from dashipy.components import Plot - class PlotTest(unittest.TestCase): def test_is_json_serializable(self): - figure = go.Figure(layout=Layout(title="Bar Chart", autosize=True)) - figure.add_trace(go.Bar(x=["A", "B", "C"], y=[0.2, 0.3, 0.4])) - plot = Plot(figure=figure) - - d = plot.to_dict() - self.assertIsInstance(d, dict) - self.assertIsInstance(d.get("figure"), dict) - json_text = json.dumps(d) - self.assertEqual("{", json_text[0]) - self.assertEqual("}", json_text[-1]) + pass diff --git a/dashipy/tests/lib/container_test.py b/dashipy/tests/container_test.py similarity index 100% rename from dashipy/tests/lib/container_test.py rename to dashipy/tests/container_test.py diff --git a/dashipy/tests/lib/__init__.py b/dashipy/tests/demo/__init__.py similarity index 100% rename from dashipy/tests/lib/__init__.py rename to dashipy/tests/demo/__init__.py diff --git a/dashipy/tests/demo/contribs/__init__.py b/dashipy/tests/demo/contribs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dashipy/tests/contribs/panel_test.py b/dashipy/tests/demo/contribs/panel_test.py similarity index 89% rename from dashipy/tests/contribs/panel_test.py rename to dashipy/tests/demo/contribs/panel_test.py index 021eb2e1..99317ad9 100644 --- a/dashipy/tests/contribs/panel_test.py +++ b/dashipy/tests/demo/contribs/panel_test.py @@ -4,10 +4,12 @@ from dashipy.callback import Input, Callback, Output +# noinspection PyUnusedLocal def my_callback_1(ctx): pass +# noinspection PyUnusedLocal def my_callback_2(ctx, a: int = 0, b: str = "", c: bool = False) -> str: return f"{a}-{b}-{c}" @@ -39,10 +41,7 @@ def test_callback(self): self.assertEqual(1, len(callback.outputs)) self.assertEqual("a", callback.inputs[0].id) self.assertEqual("value", callback.inputs[0].property) - self.assertEqual("Component", callback.inputs[0].kind) self.assertEqual("b", callback.inputs[1].id) self.assertEqual("value", callback.inputs[1].property) - self.assertEqual("Component", callback.inputs[1].kind) self.assertEqual("c", callback.inputs[2].id) self.assertEqual("value", callback.inputs[2].property) - self.assertEqual("Component", callback.inputs[2].kind)