Browse Source

mol-plugin & state: refactoring and fixes

David Sehnal 6 years ago
parent
commit
6769158e44

+ 1 - 1
src/mol-plugin/behavior/static/representation.ts

@@ -43,7 +43,7 @@ export function SyncRepresentationToCanvas(ctx: PluginContext) {
 }
 
 export function UpdateRepresentationVisibility(ctx: PluginContext) {
-    ctx.state.dataState.events.object.cellState.subscribe(e => {
+    ctx.state.dataState.events.cell.stateUpdated.subscribe(e => {
         const cell = e.state.cells.get(e.ref)!;
         if (!SO.isRepresentation3D(cell.obj)) return;
 

+ 6 - 4
src/mol-plugin/context.ts

@@ -30,10 +30,12 @@ export class PluginContext {
 
     readonly events = {
         state: {
+            cell: {
+                stateUpdated: merge(this.state.dataState.events.cell.stateUpdated, this.state.behaviorState.events.cell.stateUpdated),
+                created: merge(this.state.dataState.events.cell.created, this.state.behaviorState.events.cell.created),
+                removed: merge(this.state.dataState.events.cell.removed, this.state.behaviorState.events.cell.removed),
+            },
             object: {
-                cellState: merge(this.state.dataState.events.object.cellState, this.state.behaviorState.events.object.cellState),
-                cellCreated: merge(this.state.dataState.events.object.cellCreated, this.state.behaviorState.events.object.cellCreated),
-
                 created: merge(this.state.dataState.events.object.created, this.state.behaviorState.events.object.created),
                 removed: merge(this.state.dataState.events.object.removed, this.state.behaviorState.events.object.removed),
                 updated: merge(this.state.dataState.events.object.updated, this.state.behaviorState.events.object.updated)
@@ -66,9 +68,9 @@ export class PluginContext {
         try {
             (this.canvas3d as Canvas3D) = Canvas3D.create(canvas, container);
             this.canvas3d.animate();
-            console.log('canvas3d created');
             return true;
         } catch (e) {
+            this.log(LogEntry.error('' + e));
             console.error(e);
             return false;
         }

+ 3 - 3
src/mol-plugin/state.ts

@@ -40,7 +40,7 @@ class PluginState {
     getSnapshot(): PluginState.Snapshot {
         return {
             data: this.dataState.getSnapshot(),
-            behaviours: this.behaviorState.getSnapshot(),
+            behaviour: this.behaviorState.getSnapshot(),
             cameraSnapshots: this.cameraSnapshots.getStateSnapshot(),
             canvas3d: {
                 camera: this.plugin.canvas3d.camera.getSnapshot()
@@ -49,7 +49,7 @@ class PluginState {
     }
 
     async setSnapshot(snapshot: PluginState.Snapshot) {
-        await this.plugin.runTask(this.behaviorState.setSnapshot(snapshot.behaviours));
+        // await this.plugin.runTask(this.behaviorState.setSnapshot(snapshot.behaviour));
         await this.plugin.runTask(this.dataState.setSnapshot(snapshot.data));
         this.cameraSnapshots.setStateSnapshot(snapshot.cameraSnapshots);
         this.plugin.canvas3d.camera.setState(snapshot.canvas3d.camera);
@@ -83,7 +83,7 @@ namespace PluginState {
 
     export interface Snapshot {
         data: State.Snapshot,
-        behaviours: State.Snapshot,
+        behaviour: State.Snapshot,
         cameraSnapshots: CameraSnapshotManager.StateSnapshot,
         canvas3d: {
             camera: Camera.Snapshot

+ 0 - 146
src/mol-plugin/ui/action.tsx

@@ -1,146 +0,0 @@
-/**
- * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
- *
- * @author David Sehnal <david.sehnal@gmail.com>
- */
-
-import * as React from 'react';
-import { Transform, State, Transformer } from 'mol-state';
-import { StateAction } from 'mol-state/action';
-import { PluginCommands } from 'mol-plugin/command';
-import { PluginComponent } from './base';
-import { ParameterControls, createChangeSubject, ParamChanges } from './controls/parameters';
-import { Subject } from 'rxjs';
-import { shallowEqual } from 'mol-util/object';
-
-export { ActionContol }
-
-namespace ActionContol {
-    export interface Props {
-        nodeRef: Transform.Ref,
-        state: State,
-        action?: StateAction
-    }
-}
-
-class ActionContol extends PluginComponent<ActionContol.Props, { params: any, initialParams: any, error?: string, busy: boolean, canApply: boolean }> {
-    private changes: ParamChanges;
-    private busy: Subject<boolean>;
-
-    cell = this.props.state.cells.get(this.props.nodeRef)!;
-    parentCell = (this.cell.sourceRef && this.props.state.cells.get(this.cell.sourceRef)) || void 0;
-
-    action: StateAction | Transformer = !this.props.action ? this.cell.transform.transformer : this.props.action
-    isUpdate = !this.props.action
-
-    getDefaultParams() {
-        if (this.isUpdate) {
-            return this.cell.transform.params;
-        } else {
-            const p = this.action.definition.params;
-            if (!p || !p.default) return {};
-            const obj = this.cell;
-            if (!obj.obj) return {};
-            return p.default(obj.obj, this.plugin);
-        }
-    }
-
-    getParamDefinitions() {
-        if (this.isUpdate) {
-            const cell = this.cell;
-            const def = cell.transform.transformer.definition;
-
-            if (!cell.sourceRef || !def.params || !def.params.controls) return { };
-            const src = this.parentCell;
-            if (!src || !src.obj) return { };
-
-            return def.params.controls(src.obj, this.plugin);
-        } else {
-            const p = this.action.definition.params;
-            if (!p || !p.controls) return {};
-            const cell = this.cell;
-            if (!cell.obj) return {};
-            return p.controls(cell.obj, this.plugin);
-        }
-    }
-
-    defaultState() {
-        const params = this.getDefaultParams();
-        return { error: void 0, params, initialParams: params, busy: false, canApply: !this.isUpdate };
-    }
-
-    apply = async () => {
-        this.setState({ busy: true, initialParams: this.state.params, canApply: !this.isUpdate });
-
-        try {
-            if (Transformer.is(this.action)) {
-                await this.plugin.updateTransform(this.props.state, this.props.nodeRef, this.state.params);
-            } else {
-                await PluginCommands.State.ApplyAction.dispatch(this.plugin, {
-                    state: this.props.state,
-                    action: this.action.create(this.state.params),
-                    ref: this.props.nodeRef
-                });
-            }
-        } finally {
-            this.busy.next(false);
-        }
-    }
-
-    validate(params: any) {
-        const def = this.isUpdate ? this.cell.transform.transformer.definition.params : this.action.definition.params;
-        if (!def || !def.validate) return;
-        const cell = this.cell;
-        const error = def.validate(params, this.isUpdate ? this.parentCell!.obj! : cell.obj!, this.plugin);
-        return error && error[0];
-    }
-
-    init() {
-        this.changes = createChangeSubject();
-        this.subscribe(this.changes, ({ name, value }) => {
-            const params = { ...this.state.params, [name]: value };
-            const canApply = this.isUpdate ? !shallowEqual(params, this.state.initialParams) : true;
-            this.setState({ params, error: this.validate(params), canApply });
-        });
-
-        this.busy = new Subject();
-        this.subscribe(this.busy, busy => this.setState({ busy }));
-    }
-
-    onEnter = () => {
-        if (this.state.error) return;
-        this.apply();
-    }
-
-    refresh = () => {
-        this.setState({ params: this.state.initialParams, canApply: !this.isUpdate });
-    }
-
-    state = this.defaultState()
-
-    nothingToUpdate() {
-        return <div>Nothing to update</div>;
-    }
-
-    render() {
-        const cell = this.cell;
-        if (cell.status !== 'ok' || (this.isUpdate && cell.transform.ref === Transform.RootRef)) return this.nothingToUpdate();
-
-        const paramDefs = this.getParamDefinitions();
-        if (this.isUpdate && Object.keys(paramDefs).length === 0) return this.nothingToUpdate();
-
-        const action = this.action;
-
-        return <div>
-            <div style={{ borderBottom: '1px solid #999', marginBottom: '5px' }}><h3>{(action.definition.display && action.definition.display.name) || action.id}</h3></div>
-
-            <ParameterControls params={paramDefs} values={this.state.params} changes={this.changes} onEnter={this.onEnter} isEnabled={!this.state.busy} />
-
-            <div style={{ textAlign: 'right' }}>
-                <span style={{ color: 'red' }}>{this.state.error}</span>
-                <button onClick={this.apply} disabled={!this.state.canApply || !!this.state.error || this.state.busy}>{this.isUpdate ? 'Update' : 'Create'}</button>
-                <button title='Refresh Params' onClick={this.refresh} disabled={this.state.busy}>↻</button>
-            </div>
-        </div>
-    }
-}

+ 25 - 0
src/mol-plugin/ui/base.tsx

@@ -28,6 +28,31 @@ export abstract class PluginComponent<P = {}, S = {}, SS = {}> extends React.Com
 
     protected init?(): void;
 
+    constructor(props: P, context?: any) {
+        super(props, context);
+        this.plugin = context;
+        if (this.init) this.init();
+    }
+}
+
+export abstract class PurePluginComponent<P = {}, S = {}, SS = {}> extends React.PureComponent<P, S, SS> {
+    static contextType = PluginReactContext;
+    readonly plugin: PluginContext;
+
+    private subs: Subscription[] | undefined = void 0;
+
+    protected subscribe<T>(obs: Observable<T>, action: (v: T) => void) {
+        if (typeof this.subs === 'undefined') this.subs = []
+        this.subs.push(obs.subscribe(action));
+    }
+
+    componentWillUnmount() {
+        if (!this.subs) return;
+        for (const s of this.subs) s.unsubscribe();
+    }
+
+    protected init?(): void;
+
     constructor(props: P, context?: any) {
         super(props, context);
         this.plugin = context;

+ 11 - 13
src/mol-plugin/ui/controls/parameters.tsx

@@ -7,16 +7,11 @@
 
 import * as React from 'react'
 import { ParamDefinition as PD } from 'mol-util/param-definition';
-import { Subject } from 'rxjs';
-
-export function createChangeSubject(): ParamChanges {
-    return new Subject<{ param: PD.Base<any>, name: string, value: any }>();
-}
 
 export interface ParameterControlsProps<P extends PD.Params = PD.Params> {
     params: P,
     values: any,
-    changes: ParamChanges,
+    onChange: ParamOnChange,
     isEnabled?: boolean,
     onEnter?: () => void
 }
@@ -24,7 +19,7 @@ export interface ParameterControlsProps<P extends PD.Params = PD.Params> {
 export class ParameterControls<P extends PD.Params> extends React.PureComponent<ParameterControlsProps<P>, {}> {
     render() {
         const common = {
-            changes: this.props.changes,
+            changes: this.props.onChange,
             isEnabled: this.props.isEnabled,
             onEnter: this.props.onEnter,
         }
@@ -48,14 +43,15 @@ function controlFor(param: PD.Any): ValueControl {
     }
     throw new Error('not supporter');
 }
-type ParamWrapperProps = { name: string, value: any, param: PD.Base<any>, changes: ParamChanges, control: ValueControl, onEnter?: () => void, isEnabled?: boolean }
-export type ParamChanges = Subject<{ param: PD.Base<any>, name: string, value: any }>
+
+type ParamWrapperProps = { name: string, value: any, param: PD.Base<any>, changes: ParamOnChange, control: ValueControl, onEnter?: () => void, isEnabled?: boolean }
+export type ParamOnChange = (params: { param: PD.Base<any>, name: string, value: any }) => void
 type ValueControlProps<P extends PD.Base<any> = PD.Base<any>> = { value: any, param: P, isEnabled?: boolean, onChange: (v: any) => void, onEnter?: () => void }
 type ValueControl = React.ComponentClass<ValueControlProps<any>>
 
 export class ParamWrapper extends React.PureComponent<ParamWrapperProps> {
     onChange = (value: any) => {
-        this.props.changes.next({ param: this.props.param, name: this.props.name, value });
+        this.props.changes({ param: this.props.param, name: this.props.name, value });
     }
 
     render() {
@@ -78,14 +74,16 @@ export class BoolControl extends React.PureComponent<ValueControlProps> {
     }
 }
 
-export class NumberControl extends React.PureComponent<ValueControlProps<PD.Numeric>> {
+export class NumberControl extends React.PureComponent<ValueControlProps<PD.Numeric>, { value: string }> {
+    // state = { value: this.props.value }
     onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
         this.props.onChange(+e.target.value);
+        // this.setState({ value: e.target.value });
     }
 
     render() {
         return <input type='range'
-            value={this.props.value}
+            value={'' + this.props.value}
             min={this.props.param.min}
             max={this.props.param.max}
             step={this.props.param.step}
@@ -125,7 +123,7 @@ export class SelectControl extends React.PureComponent<ValueControlProps<PD.Sele
     }
 
     render() {
-        return <select value={this.props.value} onChange={this.onChange}>
+        return <select value={this.props.value || ''} onChange={this.onChange}>
             {this.props.param.options.map(([value, label]) => <option key={label} value={value}>{label}</option>)}
         </select>;
     }

+ 6 - 6
src/mol-plugin/ui/plugin.tsx

@@ -16,8 +16,9 @@ import { List } from 'immutable';
 import { LogEntry } from 'mol-util/log-entry';
 import { formatTime } from 'mol-util';
 import { BackgroundTaskProgress } from './task';
-import { ActionContol } from './action';
+import { ApplyActionContol } from './state/apply-action';
 import { PluginState } from 'mol-plugin/state';
+import { UpdateTransformContol } from './state/update-transform';
 
 export class Plugin extends React.Component<{ plugin: PluginContext }, {}> {
     render() {
@@ -128,20 +129,19 @@ export class CurrentObject extends PluginComponent {
 
         const type = obj && obj.obj ? obj.obj.type : void 0;
 
-        console.log(obj);
+        const transform = current.state.tree.transforms.get(ref);
 
         const actions = type
             ? current.state.actions.fromType(type)
             : []
         return <div>
             <hr />
-            <h3>Update {obj.obj ? obj.obj.label : ref}</h3>
-            <ActionContol key={`${ref} update`} state={current.state} nodeRef={ref} />
+            <h3>{obj.obj ? obj.obj.label : ref}</h3>
+            <UpdateTransformContol state={current.state} transform={transform} />
             <hr />
             <h3>Create</h3>
             {
-                actions.map((act, i) => <ActionContol key={`${act.id}`}
-                    state={current.state} action={act} nodeRef={ref} />)
+                actions.map((act, i) => <ApplyActionContol plugin={this.plugin} key={`${act.id}`} state={current.state} action={act} nodeRef={ref} />)
             }
         </div>;
     }

+ 88 - 20
src/mol-plugin/ui/state-tree.tsx

@@ -12,23 +12,99 @@ import { PluginComponent } from './base';
 
 export class StateTree extends PluginComponent<{ state: State }, { }> {
     componentDidMount() {
-        this.subscribe(this.props.state.events.changed, () => this.forceUpdate());
+        // this.subscribe(this.props.state.events.changed, () => {
+        //     this.forceUpdate()
+        // });
     }
 
     render() {
         // const n = this.props.plugin.state.data.tree.nodes.get(this.props.plugin.state.data.tree.rootRef)!;
         const n = this.props.state.tree.root.ref;
         return <div>
-            <StateTreeNode state={this.props.state} nodeRef={n} key={n} />
-            { /* n.children.map(c => <StateTreeNode plugin={this.props.plugin} nodeRef={c!} key={c} />) */}
+            <StateTreeNode state={this.props.state} nodeRef={n} />
+            {/* n.children.map(c => <StateTreeNode plugin={this.props.plugin} nodeRef={c!} key={c} />) */}
         </div>;
     }
 }
 
-export class StateTreeNode extends PluginComponent<{ nodeRef: string, state: State }, { }> {
+class StateTreeNode extends PluginComponent<{ nodeRef: string, state: State }, { }> {
+    is(e: State.ObjectEvent) {
+        return e.ref === this.props.nodeRef && e.state === this.props.state;
+    }
+
+    get cellState() {
+        return this.props.state.tree.cellStates.get(this.props.nodeRef);
+    }
+
     componentDidMount() {
-        this.subscribe(this.plugin.events.state.object.cellState, o => {
-            if (o.ref === this.props.nodeRef && o.state === this.props.state) this.forceUpdate();
+        let isCollapsed = this.cellState.isCollapsed;
+        this.subscribe(this.plugin.events.state.cell.stateUpdated, e => {
+            if (this.is(e) && isCollapsed !== e.cellState.isCollapsed) {
+                isCollapsed = e.cellState.isCollapsed;
+                this.forceUpdate();
+            }
+        });
+
+        this.subscribe(this.plugin.events.state.cell.created, e => {
+            if (this.props.state === e.state && this.props.nodeRef === e.cell.transform.parent) {
+                this.forceUpdate();
+            }
+        });
+
+        this.subscribe(this.plugin.events.state.cell.removed, e => {
+            if (this.props.state === e.state && this.props.nodeRef === e.parent) {
+                this.forceUpdate();
+            }
+        });
+    }
+
+    render() {
+        const cellState = this.props.state.tree.cellStates.get(this.props.nodeRef);
+
+        const expander = <>
+            [<a href='#' onClick={e => {
+                e.preventDefault();
+                PluginCommands.State.ToggleExpanded.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
+            }}>{cellState.isCollapsed ? '+' : '-'}</a>]
+        </>;
+
+        const children = this.props.state.tree.children.get(this.props.nodeRef);
+        return <div>
+            {children.size === 0 ? void 0 : expander} <StateTreeNodeLabel nodeRef={this.props.nodeRef} state={this.props.state} />
+            {cellState.isCollapsed || children.size === 0
+                ? void 0
+                : <div style={{ marginLeft: '7px', paddingLeft: '3px', borderLeft: '1px solid #999' }}>{children.map(c => <StateTreeNode state={this.props.state} nodeRef={c!} key={c} />)}</div>
+            }
+        </div>;
+    }
+}
+
+class StateTreeNodeLabel extends PluginComponent<{ nodeRef: string, state: State }> {
+    is(e: State.ObjectEvent) {
+        return e.ref === this.props.nodeRef && e.state === this.props.state;
+    }
+
+    componentDidMount() {
+        this.subscribe(this.plugin.events.state.cell.stateUpdated, e => {
+            if (this.is(e)) this.forceUpdate();
+        });
+
+        let isCurrent = this.is(this.props.state.behaviors.currentObject.value);
+
+        this.subscribe(this.plugin.state.behavior.currentObject, e => {
+            let update = false;
+            if (this.is(e)) {
+                if (!isCurrent) {
+                    isCurrent = true;
+                    update = true;
+                }
+            } else if (isCurrent) {
+                isCurrent = false;
+                update = true;
+            }
+            if (update && e.state.tree.transforms.has(this.props.nodeRef)) {
+                this.forceUpdate();
+            }
         });
     }
 
@@ -36,6 +112,8 @@ export class StateTreeNode extends PluginComponent<{ nodeRef: string, state: Sta
         const n = this.props.state.tree.transforms.get(this.props.nodeRef)!;
         const cell = this.props.state.cells.get(this.props.nodeRef)!;
 
+        const isCurrent = this.is(this.props.state.behaviors.currentObject.value);
+
         const remove = <>[<a href='#' onClick={e => {
             e.preventDefault();
             PluginCommands.State.RemoveObject.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
@@ -58,12 +136,7 @@ export class StateTreeNode extends PluginComponent<{ nodeRef: string, state: Sta
 
         const cellState = this.props.state.tree.cellStates.get(this.props.nodeRef);
 
-        const expander = <>
-            [<a href='#' onClick={e => {
-                e.preventDefault();
-                PluginCommands.State.ToggleExpanded.dispatch(this.plugin, { state: this.props.state, ref: this.props.nodeRef });
-            }}>{cellState.isCollapsed ? '+' : '-'}</a>]
-        </>;
+        if (!cellState) console.log('missing state', this.props.nodeRef, this.props.state.tree, this.props.state.tree.transforms.has(this.props.nodeRef));
 
         const visibility = <>
             [<a href='#' onClick={e => {
@@ -72,13 +145,8 @@ export class StateTreeNode extends PluginComponent<{ nodeRef: string, state: Sta
             }}>{cellState.isHidden ? 'H' : 'V'}</a>]
         </>;
 
-        const children = this.props.state.tree.children.get(this.props.nodeRef);
-        return <div>
-            {remove}{visibility}{children.size === 0 ? void 0 : expander} {label}
-            {cellState.isCollapsed || children.size === 0
-                ? void 0
-                : <div style={{ marginLeft: '7px', paddingLeft: '3px', borderLeft: '1px solid #999' }}>{children.map(c => <StateTreeNode state={this.props.state} nodeRef={c!} key={c} />)}</div>
-            }
-        </div>;
+        return <>
+            {remove}{visibility} {isCurrent ? <b>{label}</b> : label}
+        </>;
     }
 }

+ 124 - 0
src/mol-plugin/ui/state/apply-action.tsx

@@ -0,0 +1,124 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import * as React from 'react';
+import { PluginCommands } from 'mol-plugin/command';
+import { State, Transform } from 'mol-state';
+import { StateAction } from 'mol-state/action';
+import { Subject } from 'rxjs';
+import { PurePluginComponent } from '../base';
+import { StateTransformParameters } from './parameters';
+import { memoizeOne } from 'mol-util/memoize';
+import { PluginContext } from 'mol-plugin/context';
+
+export { ApplyActionContol };
+
+namespace ApplyActionContol {
+    export interface Props {
+        plugin: PluginContext,
+        nodeRef: Transform.Ref,
+        state: State,
+        action: StateAction
+    }
+
+    export interface ComponentState {
+        nodeRef: Transform.Ref,
+        params: any,
+        error?: string,
+        busy: boolean,
+        isInitial: boolean
+    }
+}
+
+class ApplyActionContol extends PurePluginComponent<ApplyActionContol.Props, ApplyActionContol.ComponentState> {
+    private busy: Subject<boolean>;
+
+    onEnter = () => {
+        if (this.state.error) return;
+        this.apply();
+    }
+
+    source = this.props.state.cells.get(this.props.nodeRef)!.obj!;
+
+    getInfo = memoizeOne((t: Transform.Ref) => StateTransformParameters.infoFromAction(this.plugin, this.props.state, this.props.action, this.props.nodeRef));
+
+    events: StateTransformParameters.Props['events'] = {
+        onEnter: this.onEnter,
+        onChange: (params, isInitial, errors) => {
+            this.setState({ params, isInitial, error: errors && errors[0] })
+        }
+    }
+
+    // getInitialParams() {
+    //     const p = this.props.action.definition.params;
+    //     if (!p || !p.default) return {};
+    //     return p.default(this.source, this.plugin);
+    // }
+
+    // initialErrors() {
+    //     const p = this.props.action.definition.params;
+    //     if (!p || !p.validate) return void 0;
+    //     const errors = p.validate(this.info.initialValues, this.source, this.plugin);
+    //     return errors && errors[0];
+    // }
+
+    state = { nodeRef: this.props.nodeRef, error: void 0, isInitial: true, params: this.getInfo(this.props.nodeRef).initialValues, busy: false };
+
+    apply = async () => {
+        this.setState({ busy: true });
+
+        try {
+            await PluginCommands.State.ApplyAction.dispatch(this.plugin, {
+                state: this.props.state,
+                action: this.props.action.create(this.state.params),
+                ref: this.props.nodeRef
+            });
+        } finally {
+            this.busy.next(false);
+        }
+    }
+
+    init() {
+        this.busy = new Subject();
+        this.subscribe(this.busy, busy => this.setState({ busy }));
+    }
+
+    refresh = () => {
+        this.setState({ params: this.getInfo(this.props.nodeRef).initialValues, isInitial: true, error: void 0 });
+    }
+
+    static getDerivedStateFromProps(props: ApplyActionContol.Props, state: ApplyActionContol.ComponentState) {
+        if (props.nodeRef === state.nodeRef) return null;
+        const source = props.state.cells.get(props.nodeRef)!.obj!;
+        const definition = props.action.definition.params || { };
+        const initialValues = definition.default ? definition.default(source, props.plugin) : {};
+
+        const newState: Partial<ApplyActionContol.ComponentState> = {
+            nodeRef: props.nodeRef,
+            params: initialValues,
+            isInitial: true,
+            error: void 0
+        };
+        return newState;
+    }
+
+    render() {
+        const info = this.getInfo(this.props.nodeRef);
+        const action = this.props.action;
+
+        return <div>
+            <div style={{ borderBottom: '1px solid #999', marginBottom: '5px' }}><h3>{(action.definition.display && action.definition.display.name) || action.id}</h3></div>
+
+            <StateTransformParameters info={info} events={this.events} params={this.state.params} isEnabled={!this.state.busy} />
+
+            <div style={{ textAlign: 'right' }}>
+                <span style={{ color: 'red' }}>{this.state.error}</span>
+                {this.state.isInitial ? void 0 : <button title='Refresh Params' onClick={this.refresh} disabled={this.state.busy}>↻</button>}
+                <button onClick={this.apply} disabled={!!this.state.error || this.state.busy}>Create</button>
+            </div>
+        </div>
+    }
+}

+ 94 - 0
src/mol-plugin/ui/state/parameters.tsx

@@ -0,0 +1,94 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { StateObject, Transformer, State, Transform, StateObjectCell } from 'mol-state';
+import { shallowEqual } from 'mol-util/object';
+import * as React from 'react';
+import { PurePluginComponent } from '../base';
+import { ParameterControls, ParamOnChange } from '../controls/parameters';
+import { StateAction } from 'mol-state/action';
+import { PluginContext } from 'mol-plugin/context';
+import { ParamDefinition as PD } from 'mol-util/param-definition';
+
+export { StateTransformParameters };
+
+class StateTransformParameters extends PurePluginComponent<StateTransformParameters.Props> {
+    getDefinition() {
+        const controls = this.props.info.definition.controls;
+        if (!controls) return { };
+        return controls!(this.props.info.source, this.plugin)
+    }
+
+    validate(params: any) {
+        const validate = this.props.info.definition.validate;
+        if (!validate) return void 0;
+        return validate(params, this.props.info.source, this.plugin)
+    }
+
+    areInitial(params: any) {
+        const areEqual = this.props.info.definition.areEqual;
+        if (!areEqual) return shallowEqual(params, this.props.info.initialValues);
+        return areEqual(params, this.props.info.initialValues);
+    }
+
+    onChange: ParamOnChange = ({ name, value }) => {
+        const params = { ...this.props.params, [name]: value };
+        this.props.events.onChange(params, this.areInitial(params), this.validate(params));
+    };
+
+    render() {
+        return <ParameterControls params={this.props.info.params} values={this.props.params} onChange={this.onChange} onEnter={this.props.events.onEnter} isEnabled={this.props.isEnabled} />;
+    }
+}
+
+
+namespace StateTransformParameters {
+    export interface Props {
+        info: {
+            definition: Transformer.ParamsDefinition,
+            params: PD.Params,
+            initialValues: any,
+            source: StateObject,
+            isEmpty: boolean
+        },
+        events: {
+            onChange: (params: any, areInitial: boolean, errors?: string[]) => void,
+            onEnter: () => void,
+        }
+        params: any,
+        isEnabled?: boolean
+    }
+
+    export type Class = React.ComponentClass<Props>
+
+    export function infoFromAction(plugin: PluginContext, state: State, action: StateAction, nodeRef: Transform.Ref): Props['info'] {
+        const source = state.cells.get(nodeRef)!.obj!;
+        const definition = action.definition.params || { };
+        const initialValues = definition.default ? definition.default(source, plugin) : {};
+        const params = definition.controls ? definition.controls(source, plugin) : {};
+        return {
+            source,
+            definition: action.definition.params || { },
+            initialValues,
+            params,
+            isEmpty: Object.keys(params).length === 0
+        };
+    }
+
+    export function infoFromTransform(plugin: PluginContext, state: State, transform: Transform): Props['info'] {
+        const cell = state.cells.get(transform.ref)!;
+        const source: StateObjectCell | undefined = (cell.sourceRef && state.cells.get(cell.sourceRef)!) || void 0;
+        const definition = transform.transformer.definition.params || { };
+        const params = definition.controls ? definition.controls((source && source.obj) as any, plugin) : {};
+        return {
+            source: (source && source.obj) as any,
+            definition,
+            initialValues: transform.params,
+            params,
+            isEmpty: Object.keys(params).length === 0
+        }
+    }
+}

+ 98 - 0
src/mol-plugin/ui/state/update-transform.tsx

@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+import { State, Transform } from 'mol-state';
+import * as React from 'react';
+import { Subject } from 'rxjs';
+import { PurePluginComponent } from '../base';
+import { StateTransformParameters } from './parameters';
+import { memoizeOne } from 'mol-util/memoize';
+
+export { UpdateTransformContol };
+
+namespace UpdateTransformContol {
+    export interface Props {
+        transform: Transform,
+        state: State
+    }
+
+    export interface ComponentState {
+        transform: Transform,
+        params: any,
+        error?: string,
+        busy: boolean,
+        isInitial: boolean
+    }
+}
+
+class UpdateTransformContol extends PurePluginComponent<UpdateTransformContol.Props, UpdateTransformContol.ComponentState> {
+    private busy: Subject<boolean>;
+
+    onEnter = () => {
+        if (this.state.error) return;
+        this.apply();
+    }
+
+    getInfo = memoizeOne((t: Transform) => StateTransformParameters.infoFromTransform(this.plugin, this.props.state, this.props.transform));
+
+    events: StateTransformParameters.Props['events'] = {
+        onEnter: this.onEnter,
+        onChange: (params, isInitial, errors) => {
+            this.setState({ params, isInitial, error: errors && errors[0] })
+        }
+    }
+
+    state: UpdateTransformContol.ComponentState = { transform: this.props.transform, error: void 0, isInitial: true, params: this.getInfo(this.props.transform).initialValues, busy: false };
+
+    apply = async () => {
+        this.setState({ busy: true });
+
+        try {
+            await this.plugin.updateTransform(this.props.state, this.props.transform.ref, this.state.params);
+        } finally {
+            this.busy.next(false);
+        }
+    }
+
+    init() {
+        this.busy = new Subject();
+        this.subscribe(this.busy, busy => this.setState({ busy }));
+    }
+
+    refresh = () => {
+        this.setState({ params: this.props.transform.params, isInitial: true, error: void 0 });
+    }
+
+    static getDerivedStateFromProps(props: UpdateTransformContol.Props, state: UpdateTransformContol.ComponentState) {
+        if (props.transform === state.transform) return null;
+        const newState: Partial<UpdateTransformContol.ComponentState> = {
+            transform: props.transform,
+            params: props.transform.params,
+            isInitial: true,
+            error: void 0
+        };
+        return newState;
+    }
+
+    render() {
+        const info = this.getInfo(this.props.transform);
+        if (info.isEmpty) return <div>Nothing to update</div>;
+
+        const tr = this.props.transform.transformer;
+
+        return <div>
+            <div style={{ borderBottom: '1px solid #999', marginBottom: '5px' }}><h3>{(tr.definition.display && tr.definition.display.name) || tr.id}</h3></div>
+
+            <StateTransformParameters info={info} events={this.events} params={this.state.params} isEnabled={!this.state.busy} />
+
+            <div style={{ textAlign: 'right' }}>
+                <span style={{ color: 'red' }}>{this.state.error}</span>
+                {this.state.isInitial ? void 0 : <button title='Refresh Params' onClick={this.refresh} disabled={this.state.busy}>↻</button>}
+                <button onClick={this.apply} disabled={!!this.state.error || this.state.busy || this.state.isInitial}>Update</button>
+            </div>
+        </div>
+    }
+}

+ 32 - 14
src/mol-state/state.ts

@@ -31,10 +31,12 @@ class State {
 
     readonly globalContext: unknown = void 0;
     readonly events = {
+        cell: {
+            stateUpdated: this.ev<State.ObjectEvent & { cellState: StateObjectCell.State}>(),
+            created: this.ev<State.ObjectEvent & { cell: StateObjectCell }>(),
+            removed: this.ev<State.ObjectEvent & { parent: Transform.Ref }>(),
+        },
         object: {
-            cellState: this.ev<State.ObjectEvent>(),
-            cellCreated: this.ev<State.ObjectEvent>(),
-
             updated: this.ev<State.ObjectEvent & { action: 'in-place' | 'recreate', obj: StateObject, oldObj?: StateObject }>(),
             created: this.ev<State.ObjectEvent & { obj: StateObject }>(),
             removed: this.ev<State.ObjectEvent & { obj?: StateObject }>()
@@ -75,7 +77,7 @@ class State {
             : stateOrProvider;
 
         if (this._tree.updateCellState(ref, update)) {
-            this.events.object.cellState.next({ state: this, ref });
+            this.events.cell.stateUpdated.next({ state: this, ref, cellState: this.tree.cellStates.get(ref) });
         }
     }
 
@@ -196,7 +198,7 @@ async function update(ctx: UpdateContext) {
     // if only a single node was added/updated, we can skip potentially expensive diffing
     const fastTrack = !!(ctx.errorFree && ctx.editInfo && ctx.editInfo.count === 1 && ctx.editInfo.lastUpdate && ctx.editInfo.sourceTree === ctx.oldTree);
 
-    let deletes: Transform.Ref[], roots: Transform.Ref[];
+    let deletes: Transform.Ref[], deletedObjects: (StateObject | undefined)[] = [], roots: Transform.Ref[];
 
     if (fastTrack) {
         deletes = [];
@@ -224,22 +226,35 @@ async function update(ctx: UpdateContext) {
             const obj = ctx.cells.has(d) ? ctx.cells.get(d)!.obj : void 0;
             ctx.cells.delete(d);
             ctx.transformCache.delete(d);
-            ctx.parent.events.object.removed.next({ state: ctx.parent, ref: d, obj });
-            // TODO: handle current object change
+            deletedObjects.push(obj);
         }
 
         // Find roots where transform version changed or where nodes will be added.
         roots = findUpdateRoots(ctx.cells, ctx.tree);
     }
 
+    // Init empty cells where not present
+    // this is done in "pre order", meaning that "parents" will be created 1st.
+    const addedCells = initCells(ctx, roots);
+
     // Ensure cell states stay consistent
     if (!ctx.editInfo) {
         syncStates(ctx);
     }
 
-    // Init empty cells where not present
-    // this is done in "pre order", meaning that "parents" will be created 1st.
-    initCells(ctx, roots);
+    // Notify additions of new cells.
+    for (const cell of addedCells) {
+        ctx.parent.events.cell.created.next({ state: ctx.parent, ref: cell.transform.ref, cell });
+    }
+
+    for (let i = 0; i < deletes.length; i++) {
+        const d = deletes[i];
+        const parent = ctx.oldTree.transforms.get(d).parent;
+        ctx.parent.events.object.removed.next({ state: ctx.parent, ref: d, obj: deletedObjects[i] });
+        ctx.parent.events.cell.removed.next({ state: ctx.parent, ref: d, parent: parent });
+    }
+
+    if (deletedObjects.length) deletedObjects = [];
 
     // Set status of cells that will be updated to 'pending'.
     initCellStatus(ctx, roots);
@@ -292,7 +307,7 @@ function setCellStatus(ctx: UpdateContext, ref: Ref, status: StateObjectCell.Sta
     const changed = cell.status !== status;
     cell.status = status;
     cell.errorText = errorText;
-    if (changed) ctx.parent.events.object.cellState.next({ state: ctx.parent, ref });
+    if (changed) ctx.parent.events.cell.stateUpdated.next({ state: ctx.parent, ref, cellState: ctx.tree.cellStates.get(ref) });
 }
 
 function initCellStatusVisitor(t: Transform, _: any, ctx: UpdateContext) {
@@ -306,7 +321,8 @@ function initCellStatus(ctx: UpdateContext, roots: Ref[]) {
     }
 }
 
-function initCellsVisitor(transform: Transform, _: any, ctx: UpdateContext) {
+type InitCellsCtx = { ctx: UpdateContext, added: StateObjectCell[] }
+function initCellsVisitor(transform: Transform, _: any, { ctx, added }: InitCellsCtx) {
     if (ctx.cells.has(transform.ref)) {
         return;
     }
@@ -319,13 +335,15 @@ function initCellsVisitor(transform: Transform, _: any, ctx: UpdateContext) {
         errorText: void 0
     };
     ctx.cells.set(transform.ref, cell);
-    ctx.parent.events.object.cellCreated.next({ state: ctx.parent, ref: transform.ref });
+    added.push(cell);
 }
 
 function initCells(ctx: UpdateContext, roots: Ref[]) {
+    const initCtx: InitCellsCtx = { ctx, added: [] };
     for (const root of roots) {
-        StateTree.doPreOrder(ctx.tree, ctx.tree.transforms.get(root), ctx, initCellsVisitor);
+        StateTree.doPreOrder(ctx.tree, ctx.tree.transforms.get(root), initCtx, initCellsVisitor);
     }
+    return initCtx.added;
 }
 
 function findNewCurrent(ctx: UpdateContext, start: Ref, deletes: Ref[]) {

+ 12 - 10
src/mol-state/transformer.ts

@@ -47,6 +47,17 @@ export namespace Transformer {
 
     export enum UpdateResult { Unchanged, Updated, Recreate }
 
+    export interface ParamsDefinition<A extends StateObject = StateObject, P = unknown> {
+        /** Check the parameters and return a list of errors if the are not valid. */
+        default?(a: A, globalCtx: unknown): P,
+        /** Specify default control descriptors for the parameters */
+        controls?(a: A, globalCtx: unknown): ControlsFor<P>,
+        /** Check the parameters and return a list of errors if the are not valid. */
+        validate?(params: P, a: A, globalCtx: unknown): string[] | undefined,
+        /** Optional custom parameter equality. Use deep structural equal by default. */
+        areEqual?(oldParams: P, newParams: P): boolean
+    }
+
     export interface Definition<A extends StateObject = StateObject, B extends StateObject = StateObject, P = unknown> {
         readonly name: string,
         readonly from: StateObject.Ctor[],
@@ -66,16 +77,7 @@ export namespace Transformer {
          */
         update?(params: UpdateParams<A, B, P>, globalCtx: unknown): Task<UpdateResult> | UpdateResult,
 
-        readonly params?: {
-            /** Check the parameters and return a list of errors if the are not valid. */
-            default?(a: A, globalCtx: unknown): P,
-            /** Specify default control descriptors for the parameters */
-            controls?(a: A, globalCtx: unknown): ControlsFor<P>,
-            /** Check the parameters and return a list of errors if the are not valid. */
-            validate?(params: P, a: A, globalCtx: unknown): string[] | undefined,
-            /** Optional custom parameter equality. Use deep structural equal by default. */
-            areEqual?(oldParams: P, newParams: P): boolean
-        },
+        readonly params?: ParamsDefinition<A, P>,
 
         /** Test if the transform can be applied to a given node */
         isApplicable?(a: A, globalCtx: unknown): boolean,

+ 9 - 0
src/mol-state/tree/immutable.ts

@@ -163,4 +163,13 @@ namespace StateTree {
 
         return create(nodes.asImmutable(), children.asImmutable(), cellStates.asImmutable());
     }
+
+    export function dump(tree: StateTree) {
+        console.log({
+            tr: (tree.transforms as ImmutableMap<any, any>).keySeq().toArray(),
+            tr1: (tree.transforms as ImmutableMap<any, any>).valueSeq().toArray().map(t => t.ref),
+            ch: (tree.children as ImmutableMap<any, any>).keySeq().toArray(),
+            cs: (tree.cellStates as ImmutableMap<any, any>).keySeq().toArray()
+        });
+    }
 }

+ 24 - 0
src/mol-util/memoize.ts

@@ -0,0 +1,24 @@
+/**
+ * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author David Sehnal <david.sehnal@gmail.com>
+ */
+
+export function memoizeOne<Args extends any[], T>(f: (...args: Args) => T): (...args: Args) => T {
+    let lastArgs: any[] | undefined = void 0, value: any = void 0;
+    return (...args) => {
+        if (!lastArgs || lastArgs.length !== args.length) {
+            lastArgs = args;
+            value = f.apply(void 0, args);
+            return value;
+        }
+        for (let i = 0, _i = args.length; i < _i; i++) {
+            if (args[i] !== lastArgs[i]) {
+                lastArgs = args;
+                value = f.apply(void 0, args);
+                return value;
+            }
+        }
+        return value;
+    }
+}