Browse Source

mol-plugin: refactored state saving

David Sehnal 6 years ago
parent
commit
5cebfa181b

+ 7 - 0
src/examples/proteopedia-wrapper/changelog.md

@@ -0,0 +1,7 @@
+== v2.0 ==
+
+* Changed how state saving works.
+
+== v1.0 ==
+
+* Initial version.

+ 2 - 1
src/examples/proteopedia-wrapper/index.ts

@@ -22,7 +22,8 @@ import { PluginState } from 'mol-plugin/state';
 require('mol-plugin/skin/light.scss')
 
 class MolStarProteopediaWrapper {
-    static VERSION_MAJOR = 1;
+    static VERSION_MAJOR = 2;
+    static VERSION_MINOR = 0;
 
     private _ev = RxEventHelper.create();
 

+ 10 - 8
src/mol-plugin/behavior/static/state.ts

@@ -127,14 +127,15 @@ export function Snapshots(ctx: PluginContext) {
         ctx.state.snapshots.remove(id);
     });
 
-    PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description }) => {
-        const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(), name, description);
+    PluginCommands.State.Snapshots.Add.subscribe(ctx, ({ name, description, params }) => {
+        const entry = PluginStateSnapshotManager.Entry(ctx.state.getSnapshot(params), name, description);
         ctx.state.snapshots.add(entry);
     });
 
     PluginCommands.State.Snapshots.Apply.subscribe(ctx, ({ id }) => {
-        const e = ctx.state.snapshots.getEntry(id);
-        return ctx.state.setSnapshot(e.snapshot);
+        const snapshot = ctx.state.snapshots.setCurrent(id);
+        if (!snapshot) return;
+        return ctx.state.setSnapshot(snapshot);
     });
 
     PluginCommands.State.Snapshots.Upload.subscribe(ctx, ({ name, description, serverUrl }) => {
@@ -143,14 +144,15 @@ export function Snapshots(ctx: PluginContext) {
             mode: 'cors',
             referrer: 'no-referrer',
             headers: { 'Content-Type': 'application/json; charset=utf-8' },
-            body: JSON.stringify(ctx.state.getSnapshot())
+            body: JSON.stringify(ctx.state.snapshots.getRemoteSnapshot())
         }) as any as Promise<void>;
     });
 
     PluginCommands.State.Snapshots.Fetch.subscribe(ctx, async ({ url }) => {
-        const req = await fetch(url, { referrer: 'no-referrer' });
-        const json = await req.json();
-        return ctx.state.setSnapshot(json.data);
+        const json = await ctx.runTask(ctx.fetch({ url, type: 'json' })); //  fetch(url, { referrer: 'no-referrer' });
+        const current = ctx.state.snapshots.setRemoteSnapshot(json.data);
+        if (!current) return;
+        return ctx.state.setSnapshot(current);
     });
 
     PluginCommands.State.Snapshots.DownloadToFile.subscribe(ctx, ({ name }) => {

+ 2 - 1
src/mol-plugin/command.ts

@@ -10,6 +10,7 @@ import { StateTransform, State, StateAction } from 'mol-state';
 import { Canvas3DProps } from 'mol-canvas3d/canvas3d';
 import { PluginLayoutStateProps } from './layout';
 import { StructureElement } from 'mol-model/structure';
+import { PluginState } from './state';
 
 export * from './command/base';
 
@@ -27,7 +28,7 @@ export const PluginCommands = {
         ClearHighlight: PluginCommand<{ state: State, ref: StateTransform.Ref }>({ isImmediate: true }),
 
         Snapshots: {
-            Add: PluginCommand<{ name?: string, description?: string }>({ isImmediate: true }),
+            Add: PluginCommand<{ name?: string, description?: string, params?: PluginState.GetSnapshotParams }>({ isImmediate: true }),
             Remove: PluginCommand<{ id: string }>({ isImmediate: true }),
             Apply: PluginCommand<{ id: string }>({ isImmediate: true }),
             Clear: PluginCommand<{}>({ isImmediate: true }),

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

@@ -42,7 +42,7 @@ class PluginState {
         }
     }
 
-    getSnapshot(params?: Partial<PluginState.GetSnapshotParams>): PluginState.Snapshot {
+    getSnapshot(params?: PluginState.GetSnapshotParams): PluginState.Snapshot {
         const p = { ...PluginState.DefaultGetSnapshotParams, ...params };
         return {
             id: UUID.create22(),
@@ -119,7 +119,7 @@ namespace PluginState {
         cameraSnapshots: PD.Boolean(false),
         cameraTranstionStyle: PD.Select<CameraTransitionStyle>('animate', [['animate', 'Animate'], ['instant', 'Instant']])
     };
-    export type GetSnapshotParams = PD.Value<typeof GetSnapshotParams>
+    export type GetSnapshotParams = Partial<PD.Value<typeof GetSnapshotParams>>
     export const DefaultGetSnapshotParams = PD.getDefaultValues(GetSnapshotParams);
 
     export interface Snapshot {

+ 1 - 2
src/mol-plugin/ui/plugin.tsx

@@ -11,7 +11,6 @@ import { LogEntry } from 'mol-util/log-entry';
 import * as React from 'react';
 import { PluginContext } from '../context';
 import { PluginReactContext, PluginUIComponent } from './base';
-import { CameraSnapshots } from './camera';
 import { LociLabelControl, TrajectoryControls } from './controls';
 import { StateSnapshots } from './state';
 import { StateObjectActions } from './state/actions';
@@ -75,7 +74,7 @@ export class ControlsWrapper extends PluginUIComponent {
         return <div className='msp-scrollable-container msp-right-controls'>
             <CurrentObject />
             <AnimationControls />
-            <CameraSnapshots />
+            {/* <CameraSnapshots /> */}
             <StateSnapshots />
         </div>;
     }

+ 160 - 76
src/mol-plugin/ui/state.tsx

@@ -6,63 +6,63 @@
 
 import { PluginCommands } from 'mol-plugin/command';
 import * as React from 'react';
-import { PluginUIComponent } from './base';
+import { PluginUIComponent, PurePluginUIComponent } from './base';
 import { shallowEqual } from 'mol-util';
-import { List } from 'immutable';
+import { OrderedMap } from 'immutable';
 import { ParameterControls } from './controls/parameters';
 import { ParamDefinition as PD} from 'mol-util/param-definition';
-import { Subject } from 'rxjs';
+import { PluginState } from 'mol-plugin/state';
+import { urlCombine } from 'mol-util/url';
 
-export class StateSnapshots extends PluginUIComponent<{ }, { serverUrl: string }> {
-    state = { serverUrl: 'https://webchem.ncbr.muni.cz/molstar-state' }
-
-    updateServerUrl = (serverUrl: string) => { this.setState({ serverUrl }) };
+export class StateSnapshots extends PluginUIComponent<{ }> {
 
     render() {
         return <div>
-            <div className='msp-section-header'>State Snapshots</div>
-            <StateSnapshotControls serverUrl={this.state.serverUrl} serverChanged={this.updateServerUrl} />
+            <div className='msp-section-header'>State</div>
+            <StateSnapshotControls />
             <LocalStateSnapshotList />
-            <RemoteStateSnapshotList serverUrl={this.state.serverUrl} />
+            <RemoteStateSnapshots />
         </div>;
     }
 }
 
-// TODO: this is not nice: device some custom event system.
-const UploadedEvent = new Subject();
+class StateSnapshotControls extends PluginUIComponent<
+    { },
+    { params: PD.Values<typeof StateSnapshotControls.Params> }> {
 
-class StateSnapshotControls extends PluginUIComponent<{ serverUrl: string, serverChanged: (url: string) => void }, { name: string, description: string, serverUrl: string, isUploading: boolean }> {
-    state = { name: '', description: '', serverUrl: this.props.serverUrl, isUploading: false };
+    state = { params: PD.getDefaultValues(StateSnapshotControls.Params) };
 
     static Params = {
         name: PD.Text(),
-        description: PD.Text(),
-        serverUrl: PD.Text()
-    }
+        options: PD.Group({
+            description: PD.Text(),
+            ...PluginState.GetSnapshotParams
+        })
+    };
 
     add = () => {
-        PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.name, description: this.state.description });
-        this.setState({ name: '', description: '' })
+        PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description });
+        this.setState({
+            params: {
+                name: '',
+                options: {
+                    ...this.state.params.options,
+                    description: ''
+                }
+            }
+        });
     }
 
     clear = () => {
         PluginCommands.State.Snapshots.Clear.dispatch(this.plugin, {});
     }
 
-    shouldComponentUpdate(nextProps: { serverUrl: string, serverChanged: (url: string) => void }, nextState: { name: string, description: string, serverUrl: string, isUploading: boolean }) {
+    shouldComponentUpdate(nextProps: any, nextState: any) {
         return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState);
     }
 
-    upload = async () => {
-        this.setState({ isUploading: true });
-        await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, { name: this.state.name, description: this.state.description, serverUrl: this.state.serverUrl });
-        this.setState({ isUploading: false });
-        this.plugin.log.message('Snapshot uploaded.');
-        UploadedEvent.next();
-    }
-
-    download = () => {
-        PluginCommands.State.Snapshots.DownloadToFile.dispatch(this.plugin, { name: this.state.name });
+    downloadToFile = () => {
+        PluginCommands.State.Snapshots.DownloadToFile.dispatch(this.plugin, { name: this.state.params.name });
     }
 
     open = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -72,23 +72,24 @@ class StateSnapshotControls extends PluginUIComponent<{ serverUrl: string, serve
     }
 
     render() {
+        // TODO: proper styling
         return <div>
-            <ParameterControls params={StateSnapshotControls.Params} values={this.state} onEnter={this.add} onChange={p => {
-                this.setState({ [p.name]: p.value } as any);
-                if (p.name === 'serverUrl') this.props.serverChanged(p.value);
+            <div className='msp-btn-row-group' style={{ marginBottom: '10px' }}>
+                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.downloadToFile}>Download JSON</button>
+                <div className='msp-btn msp-btn-block msp-btn-action msp-loader-msp-btn-file'>
+                    {'Open JSON'} <input onChange={this.open} type='file' multiple={false} accept='.json' />
+                </div>
+            </div>
+
+            <ParameterControls params={StateSnapshotControls.Params} values={this.state.params} onEnter={this.add} onChange={p => {
+                this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any);
             }}/>
 
             <div className='msp-btn-row-group'>
-                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.add}>Add Local</button>
-                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.upload} disabled={this.state.isUploading}>Upload</button>
+                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.add}>Save</button>
+                {/* <button className='msp-btn msp-btn-block msp-form-control' onClick={this.upload} disabled={this.state.isUploading}>Upload</button> */}
                 <button className='msp-btn msp-btn-block msp-form-control' onClick={this.clear}>Clear</button>
             </div>
-            <div className='msp-btn-row-group'>
-                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.download}>Download JSON</button>
-                <div className='msp-btn msp-btn-block msp-btn-action msp-loader-msp-btn-file'>
-                    {'Open JSON'} <input onChange={this.open} type='file' multiple={false} accept='.json' />
-                </div>
-            </div>
         </div>;
     }
 }
@@ -109,9 +110,12 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> {
     }
 
     render() {
+        const current = this.plugin.state.snapshots.state.current;
         return <ul style={{ listStyle: 'none' }} className='msp-state-list'>
             {this.plugin.state.snapshots.state.entries.valueSeq().map(e =><li key={e!.snapshot.id}>
-                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.snapshot.id)}>{e!.name || new Date(e!.timestamp).toLocaleString()} <small>{e!.description}</small></button>
+                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.apply(e!.snapshot.id)}>
+                    <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0}}>{e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small>{e!.description}</small>
+                </button>
                 <button onClick={this.remove(e!.snapshot.id)} className='msp-btn msp-btn-link msp-state-list-remove-button'>
                     <span className='msp-icon msp-icon-remove' />
                 </button>
@@ -121,59 +125,139 @@ class LocalStateSnapshotList extends PluginUIComponent<{ }, { }> {
 }
 
 type RemoteEntry = { url: string, removeUrl: string, timestamp: number, id: string, name: string, description: string }
-class RemoteStateSnapshotList extends PluginUIComponent<{ serverUrl: string }, { entries: List<RemoteEntry>, isFetching: boolean }> {
-    state = { entries: List<RemoteEntry>(), isFetching: false };
+class RemoteStateSnapshots extends PluginUIComponent<
+    { },
+    { params: PD.Values<typeof RemoteStateSnapshots.Params>, entries: OrderedMap<string, RemoteEntry>, isBusy: boolean }> {
+
+    state = { params: PD.getDefaultValues(RemoteStateSnapshots.Params), entries: OrderedMap<string, RemoteEntry>(), isBusy: false };
+
+    static Params = {
+        name: PD.Text(),
+        options: PD.Group({
+            description: PD.Text(),
+            serverUrl: PD.Text('https://webchem.ncbr.muni.cz/molstar-state')
+        })
+    };
 
     componentDidMount() {
-        this.subscribe(this.plugin.events.state.snapshots.changed, () => this.forceUpdate());
         this.refresh();
-        this.subscribe(UploadedEvent, this.refresh);
+        // this.subscribe(UploadedEvent, this.refresh);
+    }
+
+    serverUrl(q?: string) {
+        if (!q) return this.state.params.options.serverUrl;
+        return urlCombine(this.state.params.options.serverUrl, q);
     }
 
     refresh = async () => {
         try {
-            this.setState({ isFetching: true });
-            const req = await fetch(`${this.props.serverUrl}/list`);
-            const json: RemoteEntry[] = await req.json();
-            this.setState({
-                entries: List<RemoteEntry>(json.map((e: RemoteEntry) => ({
+            this.setState({ isBusy: true });
+            const json = await this.plugin.runTask<RemoteEntry[]>(this.plugin.fetch({ url: this.serverUrl('list'), type: 'json'  }));
+            const entries = OrderedMap<string, RemoteEntry>().asMutable();
+            for (const e of json) {
+                entries.set(e.id, {
                     ...e,
-                    url: `${this.props.serverUrl}/get/${e.id}`,
-                    removeUrl: `${this.props.serverUrl}/remove/${e.id}`
-                }))),
-                isFetching: false })
+                    url: this.serverUrl(`get/${e.id}`),
+                    removeUrl: this.serverUrl(`remove/${e.id}`)
+                });
+            }
+
+            this.setState({ entries: entries.asImmutable(), isBusy: false })
         } catch (e) {
             this.plugin.log.error('Fetching Remote Snapshots: ' + e);
-            this.setState({ entries: List<RemoteEntry>(), isFetching: false })
+            this.setState({ entries: OrderedMap(), isBusy: false })
         }
     }
 
-    fetch(url: string) {
-        return () => PluginCommands.State.Snapshots.Fetch.dispatch(this.plugin, { url });
+    upload = async () => {
+        this.setState({ isBusy: true });
+        if (this.plugin.state.snapshots.state.entries.size === 0) {
+            await PluginCommands.State.Snapshots.Add.dispatch(this.plugin, { name: this.state.params.name, description: this.state.params.options.description });
+        }
+
+        await PluginCommands.State.Snapshots.Upload.dispatch(this.plugin, {
+            name: this.state.params.name,
+            description: this.state.params.options.description,
+            serverUrl: this.state.params.options.serverUrl
+        });
+        this.setState({ isBusy: false });
+        this.plugin.log.message('Snapshot uploaded.');
+        this.refresh();
     }
 
-    remove(url: string) {
-        return async () => {
-            this.setState({ entries: List() });
-            try {
-                await fetch(url);
-            } catch { }
-            this.refresh();
+    fetch = async (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        const entry = this.state.entries.get(id);
+        if (!entry) return;
+
+        this.setState({ isBusy: true });
+        try {
+            await PluginCommands.State.Snapshots.Fetch.dispatch(this.plugin, { url: entry.url });
+        } finally {
+            this.setState({ isBusy: false });
         }
     }
 
+    remove = async (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        const entry = this.state.entries.get(id);
+        if (!entry) return;
+        this.setState({ entries: this.state.entries.remove(id) });
+
+        try {
+            await fetch(entry.removeUrl);
+        } catch { }
+    }
+
     render() {
         return <div>
-            <button title='Click to Refresh' style={{fontWeight: 'bold'}} className='msp-btn msp-btn-block msp-form-control msp-section-header' onClick={this.refresh} disabled={this.state.isFetching}>↻ Remote Snapshots</button>
-
-            <ul style={{ listStyle: 'none' }} className='msp-state-list'>
-                {this.state.entries.valueSeq().map(e =><li key={e!.id}>
-                    <button className='msp-btn msp-btn-block msp-form-control' onClick={this.fetch(e!.url)}>{e!.name || e!.timestamp} <small>{e!.description}</small></button>
-                    <button onClick={this.remove(e!.removeUrl)} className='msp-btn msp-btn-link msp-state-list-remove-button'>
-                        <span className='msp-icon msp-icon-remove' />
-                    </button>
-                </li>)}
-            </ul>
+            <div className='msp-section-header'>Remote State</div>
+
+            <ParameterControls params={RemoteStateSnapshots.Params} values={this.state.params} onEnter={this.upload} onChange={p => {
+                this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any);
+            }} isDisabled={this.state.isBusy}/>
+
+            <div className='msp-btn-row-group'>
+                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.upload} disabled={this.state.isBusy}>Upload</button>
+                <button className='msp-btn msp-btn-block msp-form-control' onClick={this.refresh} disabled={this.state.isBusy}>Refresh</button>
+            </div>
+
+            <RemoteStateSnapshotList entries={this.state.entries} isBusy={this.state.isBusy} serverUrl={this.state.params.options.serverUrl}
+                fetch={this.fetch} remove={this.remove} />
         </div>;
     }
-}
+}
+
+class RemoteStateSnapshotList extends PurePluginUIComponent<
+    { entries: OrderedMap<string, RemoteEntry>, serverUrl: string, isBusy: boolean, fetch: (e: React.MouseEvent<HTMLElement>) => void, remove: (e: React.MouseEvent<HTMLElement>) => void },
+    { }> {
+
+    open = async (e: React.MouseEvent<HTMLElement>) => {
+        const id = e.currentTarget.getAttribute('data-id');
+        if (!id) return;
+        const entry = this.props.entries.get(id);
+        if (!entry) return;
+
+        e.preventDefault();
+        let url = `${window.location}`, qi = url.indexOf('?');
+        if (qi > 0) url = url.substr(0, qi);
+
+        window.open(`${url}?snapshot-url=${encodeURIComponent(entry.url)}`, '_blank');
+    }
+
+    render() {
+        return <ul style={{ listStyle: 'none' }} className='msp-state-list'>
+            {this.props.entries.valueSeq().map(e =><li key={e!.id}>
+                <button data-id={e!.id} className='msp-btn msp-btn-block msp-form-control' onClick={this.props.fetch}
+                    disabled={this.props.isBusy} onContextMenu={this.open} title='Click to download, right-click to open in a new tab.'>
+                    {e!.name || new Date(e!.timestamp).toLocaleString()} <small>{e!.description}</small>
+                </button>
+                <button data-id={e!.id} onClick={this.props.remove} className='msp-btn msp-btn-link msp-state-list-remove-button' disabled={this.props.isBusy}>
+                    <span className='msp-icon msp-icon-remove' />
+                </button>
+            </li>)}
+        </ul>;
+    }
+}

+ 1 - 0
src/mol-util/data-source.ts

@@ -34,6 +34,7 @@ export function readFromFile(file: File, type: 'string' | 'binary') {
     return <Task<Uint8Array | string>>readFromFileInternal(file, type === 'binary');
 }
 
+// TODO: support for no-referrer
 export function ajaxGet(url: string): Task<string>
 export function ajaxGet(params: AjaxGetParams<'string'>): Task<string>
 export function ajaxGet(params: AjaxGetParams<'binary'>): Task<Uint8Array>