/** * Created by rburson on 2/10/16. */ import * as React from 'react' import { CvState, CvProps, CvBaseMixin, CvContext, CvEvent, CvEventRegistry, CvEventType, CvNavigationResult, CvActionFiredResult, CvScopeContext, CvValueProvider, CvMessage, CvMessageType, CvNavigationResultUtil, CvActionFiredResultType, CvResultCallback, CvStateChangeResult, CvStateChangeType, CvValueListener } from './catreact-core' import { PaneContext, QueryContext, EditorContext, FormContext, MenuDef, EntityRec, NavRequest, Future, Try, EntityRecUtil, ObjUtil, Log, DialogException, Either } from 'catavolt-sdk' /* Base class for all 'action' related components */ export interface CvActionBaseProps extends CvProps { /** * The actionId of this action */ actionId?:string; /** * Array of {@link CvListener}s that will be notified of any actions fired */ actionListeners?:Array<(event:CvEvent)=>void> /** * If this callback function is provided, automatically fire this action immediately upon mounting and * return the result via the callback */ fireOnLoad?:CvResultCallback; /** * The sdk {MenuDef} that points to this action. If not specified the containing sdk {PaneContext} will * be searched for the matching MenuDef */ menuDef?:MenuDef; /** * Register to receive {@link CvEvent}s of type {@link CvNavigationResult} */ navigationListeners?:Array<(event:CvEvent)=>void> /** * Provide a target for any navigations that originate from this action. The navTarget should * correspond to the 'targetId' value of the coresident {@link CvNavigation} or {@link CvNavigator}. * This is useful for single page apps where components may be coresident. */ navTarget?:string; /** * The sdk {PaneContext} associated with this action. If not specified, the context chain will be upwardly * searched to find a component with a containing sdk {PaneContext} scopeObj */ paneContext?:PaneContext; /** * Provides any target values that may have been 'selected' when firing this action */ selectionProvider?:CvValueProvider>; /** * Render override function. It should accept the following params: * @param cvContext The current cvContext. The cvContext.cvScope.scopeObj will be the sdk {MenuDef} object * @param callback A callback for firing the action */ renderer?:(cvContext:CvContext, callback?:CvActionCallback)=>{} /** * Name of the wrapping element (should be component name) * Defaults to 'span' from backward compatibility's sake */ wrapperElemName?:string; /** * Props for the wrapping element */ wrapperElemProps?:any; /** * Name of the wrapperElem property that fires the action (i.e. onClick or onPress) * Defaults to 'onClick' */ wrapperEventHandlerName?:string; /** * Register to receive {@link CvEvent}s of type {@link CvStateChangeResult} */ stateChangeListeners?:Array<(event:CvEvent)=>void>; /** * Allows the specification of a handler for 'client' side actions. This is a special class of actions * that require some type of special processing by the client. (i.e. #search, #refresh, #calulateStatistics, etc.) */ actionHandler?:CvValueListener; } export interface CvActionBaseState extends CvState { selectedTargets:Array; } export interface CvActionHandlerParams { event:CvEvent; callback:CvResultCallback; } export interface CvActionCallback { fireAction(resultCallback?:CvResultCallback):void; } export var CvActionBase = { /* Find a PaneContext in the hierarchy that has a MenuDef with this actionId */ findPaneContextWithActionId: function (actionId:string) { let paneContext:PaneContext = null; if (this.props.paneContext) { const menuDef = this.props.paneContext.findMenuDefAt(actionId); if (menuDef) { paneContext = this.props.paneContext; } } else { let scopeCtx:CvScopeContext = this.findFirstScopeCtx((scopeCtx:CvScopeContext)=> { if (scopeCtx.scopeObj instanceof PaneContext) { return (scopeCtx.scopeObj as PaneContext).findMenuDefAt(actionId) != null; } else { return false; } }); if (scopeCtx) { paneContext = scopeCtx.scopeObj; } } return paneContext; }, getSelectedTargets: function () { return this.state.selectedTargets || []; }, paneContext: function () { var paneContext:PaneContext = null; if (this.props.paneContext) { paneContext = this.props.paneContext; } else if (this.props.actionId) { paneContext = this.findPaneContextWithActionId(this.props.actionId); } return paneContext ? paneContext : this.findPaneContext(); }, menuDef: function () { if (this.props.menuDef) { return this.props.menuDef; } else { const paneContext:PaneContext = this.paneContext(); return paneContext.findMenuDefAt(this.props.actionId); } }, performAction: function (resultCallback?:CvResultCallback, presaveExecuted?:boolean, destroyRequested?:boolean) { const paneContext = this.paneContext(); if (paneContext) { const menuDef:MenuDef = this.menuDef(); if (menuDef) { let result:Future = null; /* * Only navigate if the action is a not a 'client-side' action. * If it is, the 'action' will still be published so that the client can handle it appropriately. */ const actionMeta = this._getActionMeta(menuDef.actionId); if (!actionMeta.isClientAction) { if (paneContext instanceof QueryContext) { result = (paneContext as QueryContext).performMenuAction(menuDef, this.getSelectedTargets()); } else if (paneContext instanceof EditorContext) { const editorContext:EditorContext = paneContext as EditorContext; const entityRec:EntityRec = editorContext.entityRec; /* @TODO maybe we want to save changes here, maybe not... they are currently discarded */ const pendingWrites = EntityRecUtil.newEntityRec(entityRec.objectId, []); if (editorContext.isWriteMode && menuDef.isPresaveDirective && !presaveExecuted) { // Perform the write if needed. By design, if there is a navigation as a result // of the write it is ignored. The purpose of the write is to setup the action. let writeResult:Future> = editorContext.write({"followupAction":menuDef.actionId}); writeResult.onComplete((writeTry:Try>) => { if (writeTry.isFailure) { const event:CvEvent = { type: CvEventType.MESSAGE, eventObj: { message: 'Save changes failed', messageObj: writeTry.failure, type: CvMessageType.ERROR } } this.eventRegistry().publish(event, false); if(resultCallback && typeof resultCallback === 'function') { resultCallback(null, Error(writeTry.failure)); } } else { // With a successful write, proceed with executing the action. this.performAction(resultCallback, true, editorContext.isDestroyRequested); } }); } else { result = editorContext.performMenuAction(menuDef, pendingWrites); } } else if (paneContext instanceof FormContext) { result = (paneContext as FormContext).performMenuAction(menuDef); } } /* * Client Action - no server involvement * Note: client actions are handled supplying an 'actionHandler' propery to the CvAction * resultCallbacks will only be called for successful or failed 'Navigations' (non-client actions) */ if(actionMeta.isClientAction) { /* let the designated handler perform the action */ if(this.props.actionHandler) { /* Publish the action fired */ const event = this._publishActionStarted(menuDef.actionId, paneContext, actionMeta.isClientAction, this.props.actionListeners, this.eventRegistry()); this.props.actionHandler({event: event, callback: (success, error)=>{ if(error) { const de = new DialogException(); de.message = '' + error; const event:CvEvent = { type: CvEventType.MESSAGE, eventObj: { message: '' + error, messageObj: de, type: CvMessageType.ERROR} } this.eventRegistry().publish(event, false); } /* Publish the action finished */ this._publishActionFinished(menuDef.actionId, paneContext, this.props.actionListeners, this.eventRegistry()); }}); } else { /* If there's no handler for a client action, publish both start and finish events */ this._publishActionStarted(menuDef.actionId, paneContext, actionMeta.isClientAction, this.props.actionListeners, this.eventRegistry()); this._publishActionFinished(menuDef.actionId, paneContext, this.props.actionListeners, this.eventRegistry()); } /* * Otherwise the server has supplied us with a result */ } else if (result) { if (destroyRequested) { paneContext.destroy(); } /* Publish the action fired */ const event = this._publishActionStarted(menuDef.actionId, paneContext, actionMeta.isClientAction, this.props.actionListeners, this.eventRegistry()); /* Publish the navigation */ result.onComplete((navRequestTry:Try) => { /* Publish the action finished */ this._publishActionFinished(menuDef.actionId, paneContext, this.props.actionListeners, this.eventRegistry()); if (navRequestTry.isSuccess) { const e:CvEvent = CvNavigationResultUtil.publishNavigation(this.catavolt(), this.eventRegistry(), navRequestTry.success, this.props.actionId, null, this.props.navTarget, this.props.navigationListeners, paneContext.isDestroyed, actionMeta.noTransition); if(resultCallback && typeof resultCallback === 'function'){ resultCallback(e.eventObj); } this._checkDestroyed(paneContext); } else { const event:CvEvent = {type:CvEventType.MESSAGE, eventObj:{message:'Navigation failed', messageObj:navRequestTry.failure, type: CvMessageType.ERROR}} if(resultCallback && typeof resultCallback === 'function') { resultCallback(null, navRequestTry.failure); } this.eventRegistry().publish(event, false); if (destroyRequested) { // If a presave, it may be destroyed even if the action failed this._checkDestroyed(paneContext); } } }); } } } else { Log.info("Failed to find menuDef for action: " + this.props.actionId); } }, _checkDestroyed: function (paneContext:PaneContext) { if (paneContext && paneContext.isDestroyed) { const event:CvEvent = { type: CvEventType.STATE_CHANGE, resourceId: null, eventObj: {source: paneContext, type: CvStateChangeType.DESTROYED} }; this.props.stateChangeListeners.forEach(listener=> { listener(event) }); } }, _componentDidMount: function() { if(this.props.selectionProvider) { this.props.selectionProvider.subscribe(this._setSelection); } if(this.props.fireOnLoad) { this._getCallbackObj().fireAction(this.props.fireOnLoad); } }, _componentWillReceiveProps: function(nextProps) { if(nextProps.selectionProvider && (nextProps.selectionProvider != this.props.selectionProvider)) { nextProps.selectionProvider.subscribe(this._setSelection); } if(nextProps.fireOnUpdate) { this._getCallbackObj().fireAction(nextProps.fireOnUpdate); } }, _shouldComponentUpdate: function(nextProps, nextState) { //don't update for a selection change if(this.state.selectedTargets != null && nextState.selectedTargets != null) { if(this.state.selectedTargets.length != nextState.selectedTargets.length) { return false; } } }, _getCallbackObj: function ():CvActionCallback { return { fireAction: (resultCallback?:CvResultCallback):void => { this.performAction(resultCallback); } } }, _getInitialState: function() { return {selectedTargets: []} }, _setSelection(selectedTargets:Array) { this.setState({selectedTargets: selectedTargets}); }, /** static methods */ /* static */ /* intended for resuse by other components */ _getActionMeta: function(actionId:string):{isClientAction; noTransition} { //treat search like a server action //searchQuery currently has a server-side bug if null is submitted for 'keyword' //if(actionId === "#search" || actionId === 'searchQuery') { if(actionId === "#search") { return {isClientAction: false, noTransition: true} } else { const isClientAction = actionId === 'clear' || (actionId.substr(0, 1) === "#"); return {isClientAction: isClientAction, noTransition: false} } }, /* static */ /* intended for resuse by other components */ _publishActionStarted: function (actionId:string, source:PaneContext, clientAction:boolean, actionListeners:Array<(event:CvEvent)=>void>, eventRegistry:CvEventRegistry):CvEvent { const e:CvEvent = { type: CvEventType.ACTION_FIRED, eventObj: { actionId: actionId, type: CvActionFiredResultType.ACTION_STARTED, source: source, clientAction: clientAction } }; actionListeners.forEach((listener)=> { listener(e) }); eventRegistry.publish(e, false); return e; }, /* static */ /* intended for resuse by other components */ _publishActionFinished: function (actionId:string, source:PaneContext, actionListeners:Array<(event:CvEvent)=>void>, eventRegistry:CvEventRegistry):CvEvent { const e:CvEvent = { type: CvEventType.ACTION_FIRED, eventObj: {actionId: actionId, type: CvActionFiredResultType.ACTION_COMPLETED, source: source, clientAction:false} }; actionListeners.forEach((listener)=> { listener(e) }); eventRegistry.publish(e, false); return e; }, /** End static methods */ }; export interface CvActionState extends CvActionBaseState { } export interface CvActionProps extends CvActionBaseProps { } /* *************************************************** * A catavolt action *************************************************** */ export var CvAction = React.createClass({ mixins: [CvBaseMixin, CvActionBase], componentDidMount: function() { this._componentDidMount(); }, componentWillReceiveProps(nextProps) { this._componentWillReceiveProps(nextProps); }, getChildContext: function () { const ctx = this.getDefaultChildContext(); ctx.cvContext.scopeCtx.scopeObj = this.menuDef(); return ctx; }, getDefaultProps: function () { return { actionId: null, fireOnLoad:null, paneContext: null, navTarget: null, menuDef: null, actionListeners: [], navigationListeners: [], stateChangeListeners: [], selectionProvider: null, renderer: null, wrapperElemName: 'span', wrapperElemProps: {}, wrapperEventHandlerName: 'onClick', actionHandlerProvider: null } }, getInitialState: function() { return this._getInitialState(); }, render: function () { if (this.props.renderer) { return this.props.renderer(this.getChildContext().cvContext, this._getCallbackObj()) } else if (this.props.children){ const props = ObjUtil.addAllProps(this.props.wrapperElemProps, {}); props[this.props.wrapperEventHandlerName] = ()=>{this.performAction()}; return React.createElement(this.props.wrapperElemName, props, this.props.children); } else { return null; } } });