Browse Source

[ui] Vue 3 - Web Component Wrapper - Supports reactive attributes, events & slots.

sreenaths 4 years ago
parent
commit
dfcfc0f245

+ 20 - 3
desktop/core/src/desktop/js/vue/webComponentWrap.ts

@@ -1,4 +1,20 @@
-import { ComponentOptions, Component, ComponentInternalInstance } from 'vue';
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { ComponentOptions, Component } from 'vue';
 import wrapper from './wrapper/index';
 
 export interface HueComponentOptions<T extends Component> extends ComponentOptions<T> {
@@ -12,10 +28,11 @@ const isRegistered = function(tag: string): boolean {
 export const wrap = <T extends Component>(
   tag: string,
   component: { new (): T },
-  options?: ElementDefinitionOptions
+  options?: ElementDefinitionOptions,
+  eventNames?: string[]
 ): void => {
   if(!isRegistered(tag)) {
-    const customElement: CustomElementConstructor = wrapper(component);
+    const customElement: CustomElementConstructor = wrapper(component, eventNames);
     window.customElements.define(tag, customElement, options);
   }
 };

+ 142 - 142
desktop/core/src/desktop/js/vue/wrapper/index.ts

@@ -1,25 +1,47 @@
-import { Component, h, createApp, App, ComponentPublicInstance } from 'vue';
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// 'License'); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an 'AS IS' BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/**
+ * Port of https://github.com/vuejs/vue-web-component-wrapper/blob/master/src/index.js for Vue 3 support
+ * Mustb be removed once @vuejs/vue-web-component-wrapper starts supporting Vue 3
+*/
+
+import { Component, h, createApp, App, ComponentPublicInstance, VNode, ComponentOptionsWithObjectProps } from 'vue';
+
+import { toHandlerKey } from '@vue/shared';
 
 import {
+  KeyHash,
   toVNodes,
   camelize,
   hyphenate,
   callHooks,
-  injectHook,
-  getInitialProps,
+  setInitialProps,
   createCustomEvent,
   convertAttributeValue
 } from './utils'
 
-type ComponentInternal  = Partial<Component> &  {
-  props: { [key: string]: any }
-}
-
-type KeyHash = { [key: string]: any };
-
-export default function wrap (comp: Component) {
+/**
+ * Vue 3 wrapper to convert a Vue component into Web Component. Supports reactive attributes, events & slots.
+ * @param component: Component - Component object created using Vue's defineComponent function
+ * @param eventNames: string[] - Event names to be fired in kabab case. Events must be added using addEventListener, inline events doesnt work as of now.
+*/
+export default function wrap (component: Component, eventNames: string[] = []) {
 
-  const component: ComponentInternal = <ComponentInternal><unknown>comp;
+  const componentObj: ComponentOptionsWithObjectProps = <ComponentOptionsWithObjectProps>component;
 
   let isInitialized = false;
 
@@ -27,171 +49,149 @@ export default function wrap (comp: Component) {
   let camelizedPropsList: string [];
   let camelizedPropsMap: KeyHash;
 
-  function initialize (component: ComponentInternal) {
+  function initialize () {
     if (isInitialized) return;
 
     // extract props info
-    const propsList: string[] = Array.isArray(component.props)
-      ? component.props
-      : Object.keys(component.props || {})
+    const propsList: string[] = Array.isArray(componentObj.props)
+      ? componentObj.props
+      : Object.keys(componentObj.props || {})
     hyphenatedPropsList = propsList.map(hyphenate)
     camelizedPropsList = propsList.map(camelize)
 
-    const originalPropsAsObject = Array.isArray(component.props) ? {} : component.props || {}
+    const originalPropsAsObject = Array.isArray(componentObj.props) ? {} : componentObj.props || {}
     camelizedPropsMap = camelizedPropsList.reduce((map: KeyHash, key, i) => {
       map[key] = originalPropsAsObject[propsList[i]]
       return map
     }, {})
 
-    // In Vue3 beforeCreate and created are replaced by the setup method
-
-    // // proxy $emit to native DOM events
-    // injectHook(component, 'setup', function () {
-    //   const emit = this.$emit
-    //   this.$emit = (name, ...args) => {
-    //     this.$root.$options.customElement.dispatchEvent(createCustomEvent(name, args))
-    //     return emit.call(this, name, ...args)
-    //   }
-    // })
-
-    // injectHook(component, 'created', function () {
-    //   // sync default props values to wrapper on created
-    //   camelizedPropsList.forEach(key => {
-    //     this.$root.props[key] = this[key]
-    //   })
-    // })
-
-    // proxy props as Element properties
-    camelizedPropsList.forEach(key => {
-      Object.defineProperty(CustomElement.prototype, key, {
-        get () {
-          return this._wrapper.props[key]
-        },
-        set (newVal) {
-          this._wrapper.props[key] = newVal
-        },
-        enumerable: false,
-        configurable: true
-      })
-    })
-
-    isInitialized = true
+    isInitialized = true;
   }
 
-  // function syncAttribute (el: CustomElement, key: string): void {
-  //   const camelized = camelize(key)
-  //   const value = el.hasAttribute(key) ? el.getAttribute(key) : undefined
-  //   el._wrapper.props[camelized] = convertAttributeValue(
-  //     value,
-  //     key,
-  //     camelizedPropsMap[camelized]
-  //   )
-  // }
-
   class CustomElement extends HTMLElement {
     _wrapper: App;
     _component?: ComponentPublicInstance;
 
+    _props!: KeyHash;
+    _slotChildren!: (VNode | null)[];
+    _emit!: Function;
+    _mounted: boolean = false;
+
     constructor () {
-      const self = super()
+      super()
 
-      //this.attachShadow({ mode: 'open' })
+      const eventProxies = this.createEventProxies(eventNames);
 
-      const wrapper: App = this._wrapper = createApp({
-        name: 'shadow-root',
-        data () {
-          return {
-            props: {},
-            slotChildren: []
-          }
-        },
+      this._props = {};
+      this._slotChildren = [];
+
+      const self = this;
+      this._wrapper = createApp({
         render () {
-          return h(component, {
-            // ref: 'inner',
-            props: this.props
-          }, this.slotChildren)
+          return h(componentObj, Object.assign({}, self._props, eventProxies), () => self._slotChildren);
+        },
+        mounted() {
+          self._mounted = true;
+        },
+        unmounted() {
+          self._mounted = false;
+        }
+      });
+
+      // Use MutationObserver to react to future attribute & slot content change
+      const observer = new MutationObserver(mutations => {
+        let hasChildrenChange = false;
+
+        for (let i = 0; i < mutations.length; i++) {
+          const m = mutations[i]
+
+          if (isInitialized && m.type === 'attributes' && m.target === this) {
+            if(m.attributeName) {
+              this.syncAttribute(m.attributeName);
+            }
+          } else {
+            hasChildrenChange = true;
+          }
         }
-      })
 
-      // // Use MutationObserver to react to future attribute & slot content change
-      // const observer = new MutationObserver(mutations => {
-      //   let hasChildrenChange = false
-      //   for (let i = 0; i < mutations.length; i++) {
-      //     const m = mutations[i]
-      //     if (isInitialized && m.type === 'attributes' && m.target === this) {
-      //       syncAttribute(this, m.attributeName)
-      //     } else {
-      //       hasChildrenChange = true
-      //     }
-      //   }
-
-      //   if (hasChildrenChange) {
-      //     // TODO: Make this working
-      //     // wrapper.slotChildren = Object.freeze(toVNodes(
-      //     //   h,
-      //     //   this.childNodes
-      //     // ))
-      //   }
-      // })
-
-      // observer.observe(this, {
-      //   childList: true,
-      //   subtree: true,
-      //   characterData: true,
-      //   attributes: true
-      // })
+        if (hasChildrenChange) {
+          //this.syncSlots(); Commenting as this is causing an infinit $forceUpdate loop, will fix if required!
+        }
+      });
+
+      observer.observe(this, {
+        childList: true,
+        subtree: true,
+        characterData: true,
+        attributes: true
+      });
     }
 
-    get vueComponent (): ComponentPublicInstance | undefined {
-        return this._component;
+    createEventProxies(eventNames: string[]): { [name: string]: Function } {
+      const eventProxies: { [name: string]: Function } = {};
+
+      eventNames.forEach(name => {
+        let handlerName = toHandlerKey(camelize(name))
+        eventProxies[handlerName] = (...args: any[]): void => {
+          this.dispatchEvent(createCustomEvent(name, args));
+        };
+      });
+
+      return eventProxies;
     }
 
-    connectedCallback () {
-      this._component = this._wrapper.mount(this);
-      callHooks(this._component, 'activated');
+    syncAttribute(key: string): void {
+      const camelized = camelize(key);
+      const value = this.hasAttribute(key) ? this.getAttribute(key) : undefined;
+
+      this._props[camelized] = convertAttributeValue(
+        value,
+        key,
+        camelizedPropsMap[camelized]
+      );
+
+      this._component?.$forceUpdate();
+    }
+
+    syncSlots(): void {
+      this._slotChildren = toVNodes(this.childNodes);
+      this._component?.$forceUpdate();
     }
 
-    // connectedCallback () {
-    //   const wrapper = this._wrapper
-    //   if (!wrapper._isMounted) {
-    //     // initialize attributes
-    //     const syncInitialAttributes = () => {
-    //       wrapper.props = getInitialProps(camelizedPropsList)
-    //       hyphenatedPropsList.forEach(key => {
-    //         syncAttribute(this, key)
-    //       })
-    //     }
-
-    //     if (isInitialized) {
-    //       syncInitialAttributes()
-    //     } else {
-    //       // async & unresolved
-    //       component().then(resolved => {
-    //         if (resolved.__esModule || resolved[Symbol.toStringTag] === 'Module') {
-    //           resolved = resolved.default
-    //         }
-    //         initialize(resolved)
-    //         syncInitialAttributes()
-    //       })
-    //     }
-
-    //     // initialize children
-    //     wrapper.slotChildren = Object.freeze(toVNodes(
-    //       h,
-    //       this.childNodes
-    //     ))
-    //     //wrapper.mount(this.shadowRoot)
-    //   } else {
-    //     callHooks(this.vueComponent, 'activated')
-    //   }
-    // }
+    syncInitialAttributes(): void {
+      this._props = setInitialProps(camelizedPropsList);
+
+      hyphenatedPropsList.forEach(key => {
+        this.syncAttribute(key);
+      })
+    }
+
+    connectedCallback () {
+      if (!this._component || !this._mounted) {
+
+        if (isInitialized) {
+          // initialize attributes
+          this.syncInitialAttributes();
+        }
+
+        // initialize children
+        this.syncSlots();
+
+        // Mount the component
+        this._component = this._wrapper.mount(this);
+
+      } else {
+        // Call mounted on re-insert
+        callHooks(this._component, 'mounted')
+      }
+    }
 
     disconnectedCallback () {
-      callHooks(this.vueComponent, 'deactivated')
+      callHooks(this._component, 'unmounted');
     }
   }
 
-  initialize(component)
+  initialize();
 
   return CustomElement
 }

+ 34 - 28
desktop/core/src/desktop/js/vue/wrapper/utils.ts

@@ -1,4 +1,6 @@
-import { Component, ComponentPublicInstance } from "vue"
+import { ComponentPublicInstance, h, VNode } from "vue";
+
+export type KeyHash = { [key: string]: any };
 
 const camelizeRE = /-(\w)/g
 export const camelize = (str: string): string => {
@@ -10,15 +12,15 @@ export const hyphenate = (str: string): string => {
   return str.replace(hyphenateRE, '-$1').toLowerCase()
 }
 
-export function getInitialProps (propsList: string[]) {
-  const res: { [key: string]: any } = {}
+export function setInitialProps(propsList: string[]): KeyHash {
+  const res: KeyHash = {}
   propsList.forEach(key => {
     res[key] = undefined
   })
   return res
 }
 
-export function injectHook (options: { [key: string]: any }, key: string, hook: Function) {
+export function injectHook (options: KeyHash, key: string, hook: Function) {
   options[key] = [].concat(options[key] || [])
   options[key].unshift(hook)
 }
@@ -40,59 +42,63 @@ export function createCustomEvent (name: string, args: any) {
   })
 }
 
-const isBoolean = val => /function Boolean/.test(String(val))
-const isNumber = val => /function Number/.test(String(val))
+const isBoolean = (val: any) => /function Boolean/.test(String(val));
+const isNumber = (val: any) => /function Number/.test(String(val));
 
-export function convertAttributeValue (value, name, { type } = {}) {
+export function convertAttributeValue (value: any, name: string, { type }: { type?: any } = {}) {
   if (isBoolean(type)) {
     if (value === 'true' || value === 'false') {
-      return value === 'true'
+      return value === 'true';
     }
     if (value === '' || value === name) {
-      return true
+      return true;
     }
-    return value != null
+    return value != null;
   } else if (isNumber(type)) {
-    const parsed = parseFloat(value, 10)
-    return isNaN(parsed) ? value : parsed
+    const parsed = parseFloat(value);
+    return isNaN(parsed) ? value : parsed;
   } else {
-    return value
+    return value;
   }
 }
 
-export function toVNodes (h, children) {
-  const res = []
+export function toVNodes (children: NodeListOf<ChildNode>): (VNode | null)[] {
+  const res: (VNode | null)[] = [];
+
   for (let i = 0, l = children.length; i < l; i++) {
-    res.push(toVNode(h, children[i]))
+    res.push(toVNode(children[i]));
   }
-  return res
+
+  return res;
 }
 
-function toVNode (h, node) {
+function toVNode (node: any): VNode | null {
   if (node.nodeType === 3) {
-    return node.data.trim() ? node.data : null
+    return node.data.trim() ? node.data : null;
   } else if (node.nodeType === 1) {
     const data = {
       attrs: getAttributes(node),
       domProps: {
         innerHTML: node.innerHTML
       }
-    }
+    };
+
     if (data.attrs.slot) {
-      data.slot = data.attrs.slot
-      delete data.attrs.slot
+      data.slot = data.attrs.slot;
+      delete data.attrs.slot;
     }
-    return h(node.tagName, data)
+
+    return h(node.tagName, data);
   } else {
     return null
   }
 }
 
-function getAttributes (node) {
-  const res = {}
+function getAttributes (node: any): KeyHash {
+  const res: KeyHash = {};
   for (let i = 0, l = node.attributes.length; i < l; i++) {
-    const attr = node.attributes[i]
-    res[attr.nodeName] = attr.nodeValue
+    const attr = node.attributes[i];
+    res[attr.nodeName] = attr.nodeValue;
   }
-  return res
+  return res;
 }