UNPKG

@extjs/reactor

Version:
778 lines (653 loc) 27.7 kB
import ReactDOM from 'react-dom'; // need to ensure ReactDOM is loaded before patching ReactComponentEnvironment.replaceNodeWithMarkup import ReactComponentEnvironment from 'react-dom/lib/ReactComponentEnvironment'; import { Component, Children, cloneElement } from 'react'; import ReactMultiChild from 'react-dom/lib/ReactMultiChild'; import DOMLazyTree from 'react-dom/lib/DOMLazyTree'; import { precacheNode } from 'react-dom/lib/ReactDOMComponentTree'; import Flags from 'react-dom/lib/ReactDOMComponentFlags'; import union from 'lodash.union'; import capitalize from 'lodash.capitalize' import defaults from 'lodash.defaults'; import cloneDeepWith from 'lodash.clonedeepwith'; import isEqual from 'lodash.isequal'; import toJSON, { ReactNodeTypes } from './toJSON'; const Ext = window.Ext; const CLASS_CACHE = { Grid: Ext.ClassManager.getByAlias('widget.grid'), Column: Ext.ClassManager.getByAlias('widget.gridcolumn'), Button: Ext.ClassManager.getByAlias('widget.button'), Menu: Ext.ClassManager.getByAlias('widget.menu'), ToolTip: Ext.ClassManager.getByAlias('widget.tooltip'), CellBase: Ext.ClassManager.get('Ext.grid.cell.Base'), WidgetCell: Ext.ClassManager.getByAlias('widget.widgetcell'), Dialog: Ext.ClassManager.getByAlias('widget.dialog'), Field: Ext.ClassManager.getByAlias('widget.field'), FitLayout: Ext.ClassManager.getByAlias('layout.fit'), TabPanel: Ext.ClassManager.getByAlias('widget.tabpanel'), RendererCell: Ext.ClassManager.getByAlias('widget.renderercell') } export default class ExtJSComponent extends Component { constructor(element) { super(element); this.cmp = null; this.el = null; this._flags = null; this._hostNode = null; this._hostParent = null; this._renderedChildren = null; this._hostContainerInfo = null; this._currentElement = element; this._topLevelWrapper = null; this.displayName = 'ExtJSComponent'; this.unmountSafely = false; // needed for serializing jest snapshots when using react-test-renderer if (process.env.NODE_ENV === 'test') { this._renderedNodeType = ReactNodeTypes.HOST; // HOST this._renderedComponent = { toJSON: () => toJSON(this) } } } // begin React renderer methods /** * Creates an Ext JS component. * This is needed by the React rendering API * @param transaction * @param nativeParent * @param nativeContainerInfo * @param context * @returns {null|*} */ mountComponent(transaction, nativeParent, nativeContainerInfo, context) { const element = this._currentElement; let renderToDOMNode; if (nativeParent instanceof ExtJSComponent) { this._hostContainerInfo = nativeParent._hostContainerInfo; // propagate _hostContainerInfo - this is needed to render dom elements inside Ext JS components } else if (nativeParent) { this._hostContainerInfo = nativeParent._hostContainerInfo; // propagate _hostContainerInfo - this is needed to render dom elements inside Ext JS components renderToDOMNode = nativeParent._hostNode; } else { this._hostContainerInfo = nativeContainerInfo; renderToDOMNode = nativeContainerInfo._node; } this._hostParent = nativeParent; // this is needed by ReactDOMComponentTree#getNodeFromInstance const config = this._createInitialConfig(element, transaction, context) let result; if (renderToDOMNode) { result = this._renderRootComponent(renderToDOMNode, config); } else { result = this.cmp = this.createExtJSComponent(config); } // this allows React internals to get the mounted instance for debug tools when using dangerouslyReplaceNodeWithMarkup // this is probably not needed in fiber if (!result.node) Object.defineProperty(result, 'node', { get: () => this.el }); // Ensure that componentWillUnmount is called on children. // We wait until the Ext JS component is destroyed rather than calling unmountChildren in unmountComponent // so that we don't unmount children during a Transition's animation. this.cmp.on('destroy', () => { this.unmountChildren(this.unmountSafely); }); this._precacheNode(); return result; } /** * Updates the component * @param nextComponent * @param transaction * @param context */ receiveComponent(nextComponent, transaction, context) { if (!this.cmp || this.cmp.destroyed) return; const props = nextComponent.props; this._rushProps(this._currentElement.props, props); this.updateChildren(this._applyDefaults(props), transaction, context); this._applyProps(this._currentElement.props, props); this._currentElement = nextComponent; } /** * Destroys the component */ unmountComponent(safely) { this.unmountSafely = safely; if (this.cmp) { if (this.cmp.destroying || this.cmp.$reactorConfig) return; const parentCmp = getParentCmp(this.cmp); // remember the parent and position in parent for dangerouslyReplaceNodeWithMarkup // this not needed in fiber let indexInParent; if (parentCmp) { if (parentCmp.indexOf) { // modern indexInParent = parentCmp.indexOf(this.cmp); } else if (parentCmp.items && parentCmp.items.indexOf) { // classic indexInParent = parentCmp.items.indexOf(this.cmp); } } if (this.reactorSettings.debug) console.log('destroy', this.cmp.$className); if (Ext.navigation && Ext.navigation.View && parentCmp && parentCmp instanceof Ext.navigation.View) { parentCmp.pop(); } else { this.cmp.destroy(); } // remember the parent and position in parent for dangerouslyReplaceNodeWithMarkup // this not needed in fiber this.el._extIndexInParent = indexInParent; this.el._extParent = parentCmp; } } /** * Returns the Ext JS component instance */ getHostNode() { return this.el; } /** * Returns the Ext JS component instance */ getPublicInstance() { return this.cmp; } // end react renderer methods _renderRootComponent(renderToDOMNode, config) { defaults(config, { height: '100%', width: '100%' }); config.renderTo = renderToDOMNode; this.cmp = this.createExtJSComponent(config); if (Ext.isClassic) { this.cmp.el.on('resize', () => this.cmp && this.cmp.updateLayout()); this.el = this.cmp.el.dom; } else { this.el = this.cmp.renderElement.dom; } return { node: this.el, children: [] }; } _applyDefaults({ defaults, children }) { if (defaults) { return Children.map(children, child => { if (child.type.prototype instanceof ExtJSComponent) { return cloneElement(child, { ...defaults, ...child.props }) } else { return child; } }) } else { return children; } } /** * Creates an Ext JS component config from react element props * @private */ _createInitialConfig(element, transaction, context) { const { type, props } = element; const config = this._createConfig(props, true); this._ensureResponsivePlugin(config); const items = [], dockedItems = []; if (props.children) { const children = this.mountChildren(this._applyDefaults(props), transaction, context); for (let i=0; i<children.length; i++) { const item = children[i]; if (item instanceof Ext.Base) { const prop = this._propForChildElement(item); if (prop) { item.$reactorConfig = true; const value = config; if (prop.array) { let array = config[prop.name]; if (!array) array = config[prop.name] = []; array.push(item); } else { config[prop.name] = prop.value || item; } } else { (item.dock ? dockedItems : items).push(item); } } else if (item.node) { items.push(wrapDOMElement(item)); } else if (typeof item === 'string') { // will get here when rendering html elements in react-test-renderer // no need to do anything } else { throw new Error('Could not render child item: ' + item); } } } if (items.length) config.items = items; if (dockedItems.length) config.dockedItems = dockedItems; return config; } /** * Determines whether a child element corresponds to a config or a container item based on the presence of a rel config or * matching other known relationships * @param {Ext.Base} item */ _propForChildElement(item) { if (item.config && item.config.rel) { if (typeof item.config.rel === 'string') { return { name: item.config.rel } } else { return item.config.rel; } } const { extJSClass } = this; if (isAssignableFrom(extJSClass, CLASS_CACHE.Button) && CLASS_CACHE.Menu && item instanceof CLASS_CACHE.Menu) { return { name: 'menu', array: false }; } else if (isAssignableFrom(extJSClass, Ext.Component) && CLASS_CACHE.ToolTip && item instanceof CLASS_CACHE.ToolTip) { return { name: 'tooltip', array: false }; } else if (CLASS_CACHE.Column && item instanceof CLASS_CACHE.Column) { return { name: 'columns', array: true }; } else if (isAssignableFrom(extJSClass, CLASS_CACHE.Column) && CLASS_CACHE.CellBase && item instanceof CLASS_CACHE.CellBase) { return { name: 'cell', array: false, value: this._cloneConfig(item) } } else if (isAssignableFrom(extJSClass, CLASS_CACHE.WidgetCell)) { return { name: 'widget', array: false, value: this._cloneConfig(item) } } else if (isAssignableFrom(extJSClass, CLASS_CACHE.Dialog) && CLASS_CACHE.Button && item instanceof CLASS_CACHE.Button) { return { name: 'buttons', array: true }; } else if (isAssignableFrom(extJSClass, CLASS_CACHE.Column) && CLASS_CACHE.Field && item instanceof CLASS_CACHE.Field) { return { name: 'editor', array: false, value: this._cloneConfig(item) }; } } _cloneConfig(item) { return { ...item.initialConfig, xclass: item.$className }; } /** * If the propName corresponds to an event listener (starts with "on" followed by a capital letter), returns the name of the event. * @param {String} propName * @param {String} */ _eventNameForProp(propName) { if (propName.match(/^on[A-Z]/)) { return propName.slice(2).toLowerCase(); } else { return null; } } /** * Creates an Ext config object for this specified props * @param {Object} props * @param {Boolean} [includeEvents] true to convert on* props to listeners, false to exclude them * @private */ _createConfig(props, includeEvents) { props = this._cloneProps(props); const config = {}; if (includeEvents) config.listeners = {}; for (let key in props) { if (props.hasOwnProperty(key)) { const value = props[key]; const eventName = this._eventNameForProp(key); if (eventName) { if (value && includeEvents) config.listeners[eventName] = value; } else if (key === 'config') { Object.assign(config, value); } else if (key !== 'children' && key !== 'defaults') { config[key.replace(/className/, 'cls')] = value; } } } const { extJSClass } = this; if (isAssignableFrom(extJSClass, CLASS_CACHE.Column) && typeof config.renderer === 'function' && CLASS_CACHE.RendererCell) { config.cell = config.cell || {}; config.cell.xtype = 'renderercell'; } return config; } _ensureResponsivePlugin(config) { if (config.responsiveConfig) { const { plugins } = config; if (plugins == null) { config.plugins = 'responsive'; } else if (Array.isArray(plugins) && plugins.indexOf('responsive') === -1) { plugins.push('responsive'); } else if (typeof plugins === 'string') { if (plugins !== 'responsive') { config.plugins = [plugins, 'responsive']; } } else if (!plugins.resposive) { plugins.responsive = true; } } } /** * Cloning props rather than passing them directly on as configs fixes issues where Ext JS mutates configs during * component initialization. One example of this is grid columns get $initParent added when the grid initializes. * @param {Object} props * @private */ _cloneProps(props) { return cloneDeepWith(props, value => { if (value instanceof Ext.Base || typeof(value) === 'function') { return value; } }) } _rushProps(oldProps, newProps) { const rushConfigs = this.extJSClass.__reactorUpdateConfigsBeforeChildren; if (!rushConfigs) return; const oldConfigs = {}, newConfigs = {} for (let name in rushConfigs) { oldConfigs[name] = oldProps[name]; newConfigs[name] = newProps[name] } this._applyProps(oldConfigs, newConfigs); } /** * Calls config setters for all react props that have changed * @private */ _applyProps(oldProps, props) { const keys = union(Object.keys(oldProps), Object.keys(props)); for (let key of keys) { const oldValue = oldProps[key], newValue = props[key]; if (key === 'children') continue; if (!isEqual(oldValue, newValue)) { const eventName = this._eventNameForProp(key); if (eventName) { this._replaceEvent(eventName, oldValue, newValue); } else { const setter = this._setterFor(key); if (setter) { const value = this._cloneProps(newValue); if (this.reactorSettings.debug) console.log(setter, newValue); this.cmp[setter](value); } } } } } /** * Detaches the old event listener and adds the new one. * @param {String} eventName * @param {Function} oldHandler * @param {Function} newHandler */ _replaceEvent(eventName, oldHandler, newHandler) { if (oldHandler) { if (this.reactorSettings.debug) console.log(`detaching old listener for ${eventName}`); this.cmp.un(eventName, oldHandler); } if (this.reactorSettings.debug) console.log(`attaching new listener for ${eventName}`); this.cmp.on(eventName, newHandler); } /** * Returns the name of the setter method for a given prop. * @param {String} prop */ _setterFor(prop) { if (prop === 'className') { prop = 'cls'; } const name = `set${this._capitalize(prop)}`; return this.cmp[name] && name; } /** * Returns the name of a getter for a given prop. * @param {String} prop */ _getterFor(prop) { const name = `get${this._capitalize(prop)}`; return this.cmp[name] && name; } /** * Capitalizes the first letter in the string * @param {String} str * @return {String} * @private */ _capitalize(str) { return capitalize(str[0]) + str.slice(1); } _precacheNode() { this._flags |= Flags.hasCachedChildNodes; if (this.el) { // will get here when rendering root component precacheNode(this, this.el) } else if (this.cmp.el) { this._doPrecacheNode(); } else if (Ext.isClassic) { // we get here when rendering child components due to lazy rendering this.cmp.on('afterrender', this._doPrecacheNode, this, { single: true }); } } _doPrecacheNode() { this.el = this.cmp.el.dom; this.el._extCmp = this.cmp; precacheNode(this, this.el) } /** * Returns the child item at the given index, only counting those items which were created by Reactor * @param {Number} n */ _toReactChildIndex(n) { let items = this.cmp.items; if (!items) return n; if (items.items) items = items.items; let found=0, i, item; for (i=0; i<items.length; i++) { item = items[i]; if (item.$createdByReactor && found++ === n) { return i; } } return i; } /** * Translates and index in props.children to an index within a config value that is an array. Use * this to determine the position of an item in props.children within the array config to which it is mapped. * @param {*} prop * @param {*} indexInChildren */ _toArrayConfigIndex(prop, indexInChildren) { let i=0, found=0; Children.forEach(this.props.children, child => { const propForChild = this._propForChildElement(child); if (propForChild && propForChild.name === prop.name) { if (i === indexInChildren) return found; found++; } }); return -1; } /** * Updates a config based on a child element * @param {Object} prop The prop descriptor (name and array) * @param {Ext.Base} value The value to set * @param {Number} [index] The index of the child element in props.children * @param {Boolean} [isArrayDelete=false] True if removing the item from an array */ _mergeConfig(prop, value, index, isArrayDelete) { const setter = this._setterFor(prop.name); if (!setter) return; if (value) value.$reactorConfig = true; if (prop.array) { const getter = this._getterFor(prop.name); if (!getter) return; const currentValue = this.cmp[getter]() || []; if (isArrayDelete) { // delete value = currentValue.filter(item => item !== value); } else if (index !== undefined) { // move value = currentValue.filter(item => item !== value); value = value.splice(this._toArrayConfigIndex(index, prop), 0, item); } else { // append value = currentValue.concat(value); } } if (this.reactorSettings.debug) console.log(setter, value); this.cmp[setter](value); } _ignoreChildrenOrder() { // maintaining order in certain components, like Transition's container, can cause problems with animations, _reactorIgnoreOrder gives us a way to opt out in such scenarios if (this.cmp._reactorIgnoreOrder) return true; // moving the main child of a container with layout fit causes it to disappear. Instead we do nothing, which // should be ok because fit containers are not ordered if (CLASS_CACHE.FitLayout && this.cmp.layout instanceof CLASS_CACHE.FitLayout) return true; // When tab to the left of the active tab is removed, the left-most tab would always be selected as the tabs to the right are reinserted if (CLASS_CACHE.TabPanel && this.cmp instanceof CLASS_CACHE.TabPanel) return true; } } /** * Extend ReactMultiChild to handle inserting and moving Component instances * within Ext JS Containers */ const ContainerMixin = Object.assign({}, ReactMultiChild.Mixin, { /** * Moves a child component to the supplied index. * @param {ExtJSComponent} child Component to move. * @param {Component} afterNode The component to move after * @param {number} toIndex Destination index of the element. * @param {number} lastIndex Last index visited of the siblings of `child`. * @protected */ moveChild(child, afterNode, toIndex, lastIndex) { if (this._ignoreChildrenOrder()) return; if (toIndex === child._mountIndex) return; // only move child if the actual mount index has changed let childComponent = toComponent(child.cmp || child.getHostNode()); const prop = this._propForChildElement(childComponent); if (prop) { this._mergeConfig(prop, childComponent, toIndex); } else if (childComponent) { if (childComponent.dock) { this.cmp.insertDocked(toIndex, childComponent); } else { // reordering docked components is known to cause issues in modern // place items in a container instead if (childComponent.config && (childComponent.config.docked || childComponent.config.floated || childComponent.config.positioned)) return; // removing the child first ensures that we get the new index correct this.cmp.remove(childComponent, false); const newIndex = this._toReactChildIndex(toIndex); if (this.reactorSettings.debug) console.log(`moving ${childComponent.$className} to position ${newIndex} in ${this.cmp.$className}`); this.cmp.insert(newIndex, childComponent); } } }, /** * Creates a child component. * @param {ExtJSComponent} child Component to create. * @param {Component} afterNode The component to move after * @param {Component} childNode The component to insert. * @protected */ createChild(child, afterNode, childNode) { const prop = this._propForChildElement(childNode); if (prop) { this._mergeConfig(prop, childNode); } else { if (!(childNode instanceof Ext.Base)) { // we're appending a dom node childNode = wrapDOMElement(childNode); } const index = this._toReactChildIndex(child._mountIndex); if (this.reactorSettings.debug) { console.log(`inserting ${childNode.$className} into ${this.cmp.$className} at position ${index}`); } this.cmp[childNode.dock ? 'insertDocked' : 'insert'](index, childNode); } }, /** * Removes a child component. * @param {ExtJSComponent} child Child to remove. * @param {Ext.Component/HTMLElement} node The node to remove * @protected */ removeChild(child, node) { const prop = child instanceof ExtJSComponent && this._propForChildElement(child.cmp); if (prop) { this._mergeConfig(prop, prop.array ? toComponent(child.cmp) : null, null, true); } else { if (node instanceof HTMLElement && node._extCmp && !node._extCmp.destroying) { if (this.reactorSettings.debug) console.log('removing', node._extCmp.$className); node._extCmp.destroy(); } // We don't need to do anything for Ext JS components because a component is automatically removed from it parent when destroyed } } }); /** * Wraps a dom element in an Ext Component so it can be added as a child item to an Ext Container. We attach * a reference to the generated Component to the dom element so it can be destroyed later if the dom element * is removed when rerendering * @param {Object} node A React node object with node, children, and text * @returns {Ext.Component} */ function wrapDOMElement(node) { let contentEl = node.node; const cmp = new Ext.Component({ // We give the wrapper component a class so that developers can reset css // properties (ex. box-sizing: context-box) for third party components. cls: 'x-react-element' }); if (cmp.element) { // modern DOMLazyTree.insertTreeBefore(cmp.element.dom, node); } else { // classic const target = document.createElement('div'); DOMLazyTree.insertTreeBefore(target, node); cmp.contentEl = contentEl instanceof HTMLElement ? contentEl : target /* text fragment or comment */; } cmp.$createdByReactor = true; contentEl._extCmp = cmp; // this is needed for devtools when using dangerouslyReplaceNodeWithMarkup // this not needed in fiber cmp.node = contentEl; return cmp; } /** * Returns the Ext Component corresponding to the given node * @param {Ext.Component/HTMLElement/DocumentFragment} node * @returns {Ext.Component} */ function toComponent(node) { if (node instanceof Ext.Base) { return node; } else if (node) { return node._extCmp; } } /** * Returns true if subClass is parentClass or a sub class of parentClass * @param {Ext.Class} subClass * @param {Ext.Class} parentClass * @return {Boolean} */ function isAssignableFrom(subClass, parentClass) { if (!subClass || !parentClass) return false; return subClass === parentClass || subClass.prototype instanceof parentClass; } /** * Returns the parent component in both modern and classic toolkits * @param {Ext.Component} cmp The child component */ function getParentCmp(cmp) { if (cmp.getParent) { // modern return cmp.getParent(); } else { // classic return cmp.ownerCt; } } // Patch replaceNodeWithMarkup to fix bugs with swapping null and components // A prime example of this is using react-router 4, which renders a null when a route fails // to match. React does not call createChild/removeChild in this case, but takes a completely separate // path through the renderer const oldReplaceNodeWithMarkup = ReactComponentEnvironment.replaceNodeWithMarkup; ReactComponentEnvironment.replaceNodeWithMarkup = function(oldChild, markup) { if (oldChild._extCmp) { const newChild = markup instanceof Ext.Base ? markup : wrapDOMElement(markup); const parent = oldChild.hasOwnProperty('_extParent') ? oldChild._extParent : getParentCmp(oldChild._extCmp); const index = oldChild.hasOwnProperty('_extIndexInParent') ? oldChild._extIndexInParent : parent.indexOf(oldChild._extCmp); parent.insert(index, newChild); oldChild._extCmp.destroy(); } else { oldReplaceNodeWithMarkup.apply(this, arguments); } } Object.assign(ExtJSComponent.prototype, ContainerMixin);