Răsfoiți Sursa

[frontend] Fix reactivity with all knockout - vue web components after the Vue 3 upgrade

This uses the Vue composition API to unify the usage of knockout variables in our Vue components.
Johan Ahlen 4 ani în urmă
părinte
comite
21d5f6811a

+ 8 - 29
desktop/core/src/desktop/js/apps/editor/components/EditorResizerKoBridge.vue

@@ -21,7 +21,7 @@
 </template>
 
 <script lang="ts">
-  import { defineComponent, PropType } from 'vue';
+  import { defineComponent, PropType, ref, toRefs } from 'vue';
 
   import { Ace } from 'ext/ace';
 
@@ -35,44 +35,23 @@
     components: {
       EditorResizer
     },
-
     props: {
       editorObservable: {
         type: Object as PropType<KnockoutObservable<Ace.Editor | undefined>>,
         required: true
       }
     },
+    setup(props) {
+      const subTracker = new SubscriptionTracker();
+      const { editorObservable } = toRefs(props);
 
-    setup(): {
-      subTracker: SubscriptionTracker;
-    } {
-      return {
-        subTracker: new SubscriptionTracker()
-      };
-    },
+      const editor = ref<Ace.Editor | null>(null);
+
+      subTracker.trackObservable(editorObservable, editor);
 
-    data(): {
-      editor: Ace.Editor | null;
-      initialized: boolean;
-    } {
       return {
-        editor: null,
-        initialized: false
+        editor
       };
-    },
-
-    updated(): void {
-      if (!this.initialized && this.editorObservable) {
-        this.editor = this.editorObservable() || null;
-        this.subTracker.subscribe(this.editorObservable, editor => {
-          this.editor = editor;
-        });
-        this.initialized = true;
-      }
-    },
-
-    unmounted(): void {
-      this.subTracker.dispose();
     }
   });
 

+ 8 - 30
desktop/core/src/desktop/js/apps/editor/components/ExecutableActionsKoBridge.vue

@@ -25,7 +25,7 @@
 </template>
 
 <script lang="ts">
-  import { defineComponent, PropType } from 'vue';
+  import { defineComponent, PropType, ref, toRefs } from 'vue';
 
   import ExecutableActions from './ExecutableActions.vue';
   import SqlExecutable from 'apps/editor/execution/sqlExecutable';
@@ -38,7 +38,6 @@
     components: {
       ExecutableActions
     },
-
     props: {
       executableObservable: {
         type: Object as PropType<KnockoutObservable<SqlExecutable | undefined>>,
@@ -49,39 +48,18 @@
         default: undefined
       }
     },
+    setup(props) {
+      const subTracker = new SubscriptionTracker();
+      const { executableObservable } = toRefs(props);
 
-    setup(): {
-      subTracker: SubscriptionTracker;
-    } {
-      return {
-        subTracker: new SubscriptionTracker()
-      };
-    },
+      const executable = ref<SqlExecutable | null>(null);
+
+      subTracker.trackObservable(executableObservable, executable);
 
-    data(): {
-      initialized: boolean;
-      executable: SqlExecutable | null;
-    } {
       return {
-        initialized: false,
-        executable: null
+        executable
       };
     },
-
-    updated(): void {
-      if (!this.initialized && this.executableObservable) {
-        this.executable = this.executableObservable() || null;
-        this.subTracker.subscribe(this.executableObservable, (executable: SqlExecutable) => {
-          this.executable = executable;
-        });
-        this.initialized = true;
-      }
-    },
-
-    unmounted(): void {
-      this.subTracker.dispose();
-    },
-
     methods: {
       limitChanged(limit: number): void {
         if (this.executable && this.executable.executor.defaultLimit) {

+ 17 - 57
desktop/core/src/desktop/js/apps/editor/components/aceEditor/AceEditorKoBridge.vue

@@ -18,7 +18,7 @@
 
 <template>
   <ace-editor
-    v-if="initialized && editorId"
+    v-if="editorId"
     :id="editorId"
     :ace-options="aceOptions"
     :executor="executor"
@@ -36,10 +36,9 @@
 </template>
 
 <script lang="ts">
-  import { defineComponent, PropType } from 'vue';
+  import { defineComponent, PropType, ref, toRefs } from 'vue';
 
   import { Ace } from 'ext/ace';
-  import { noop } from 'utils/hueUtils';
 
   import { wrap } from 'vue/webComponentWrap';
 
@@ -76,83 +75,44 @@
         default: undefined
       }
     },
-    setup() {
+    setup(props) {
+      const subTracker = new SubscriptionTracker();
+      const { cursorPositionObservable, idObservable, valueObservable } = toRefs(props);
+
+      const cursorPosition = ref<Ace.Position | null>(null);
+      const editorId = ref<string | null>(null);
+      const value = ref<string | null>(null);
+
+      subTracker.trackObservable(idObservable, editorId);
+      subTracker.trackObservable(cursorPositionObservable, cursorPosition);
+      subTracker.trackObservable(valueObservable, value);
+
       return {
+        cursorPosition,
+        editorId,
         sqlParserProvider: sqlParserRepository,
         sqlReferenceProvider: sqlReferenceRepository,
-        subTracker: new SubscriptionTracker()
+        value
       };
     },
-    data() {
-      return {
-        cursorPosition: null as Ace.Position | null,
-        editorId: null as string | null,
-        value: null as string | null,
-        initialized: false
-      };
-    },
-    updated(): void {
-      if (
-        !this.initialized &&
-        this.valueObservable &&
-        this.idObservable &&
-        this.cursorPositionObservable
-      ) {
-        this.value = this.valueObservable() || null;
-        this.subTracker.subscribe(this.valueObservable, (value?: string) => {
-          this.value = value || null;
-        });
-
-        this.editorId = this.idObservable() || null;
-        if (!this.editorId) {
-          this.subTracker
-            .whenDefined<string>(this.idObservable)
-            .then(id => {
-              this.editorId = id;
-            })
-            .catch(noop);
-        }
-
-        this.cursorPosition = this.cursorPositionObservable() || null;
-        if (!this.cursorPosition) {
-          this.subTracker
-            .whenDefined<Ace.Position>(this.cursorPositionObservable)
-            .then(position => {
-              this.cursorPosition = position;
-            })
-            .catch(noop);
-        }
-        this.initialized = true;
-      }
-    },
-
-    unmounted(): void {
-      this.subTracker.dispose();
-    },
-
     methods: {
       aceCreated(editor: Ace.Editor): void {
         this.$el.dispatchEvent(new CustomEvent('ace-created', { bubbles: true, detail: editor }));
       },
-
       createNewDoc(): void {
         this.$el.dispatchEvent(new CustomEvent('create-new-doc', { bubbles: true }));
       },
-
       cursorChanged(cursorPosition: Ace.Position): void {
         this.$el.dispatchEvent(
           new CustomEvent('cursor-changed', { bubbles: true, detail: cursorPosition })
         );
       },
-
       saveDoc(): void {
         this.$el.dispatchEvent(new CustomEvent('save-doc', { bubbles: true }));
       },
-
       togglePresentationMode(): void {
         this.$el.dispatchEvent(new CustomEvent('toggle-presentation-mode', { bubbles: true }));
       },
-
       valueChanged(value: string): void {
         this.$el.dispatchEvent(new CustomEvent('value-changed', { bubbles: true, detail: value }));
       }

+ 4 - 12
desktop/core/src/desktop/js/apps/editor/components/executionAnalysis/ExecutionAnalysisPanelKoBridge.vue

@@ -25,7 +25,7 @@
 </template>
 
 <script lang="ts">
-  import { defineComponent, PropType, onUpdated, onBeforeUnmount, ref } from 'vue';
+  import { defineComponent, PropType, ref, toRefs } from 'vue';
   import { wrap } from 'vue/webComponentWrap';
 
   import ExecutionAnalysisPanel from './ExecutionAnalysisPanel.vue';
@@ -45,19 +45,11 @@
     },
     setup(props) {
       const subTracker = new SubscriptionTracker();
-      onBeforeUnmount(subTracker.dispose.bind(subTracker));
+      const { executableObservable } = toRefs(props);
 
-      let initialized = false;
       const executable = ref<SqlExecutable | null>(null);
-      onUpdated(() => {
-        if (!initialized && props.executableObservable) {
-          executable.value = props.executableObservable() || null;
-          subTracker.subscribe(props.executableObservable, newExecutable => {
-            executable.value = newExecutable || null;
-          });
-          initialized = true;
-        }
-      });
+
+      subTracker.trackObservable(executableObservable, executable);
 
       return { executable };
     },

+ 30 - 64
desktop/core/src/desktop/js/apps/editor/components/presentationMode/PresentationModeKoBridge.vue

@@ -29,7 +29,7 @@
 </template>
 
 <script lang="ts">
-  import { defineComponent, PropType } from 'vue';
+  import { defineComponent, PropType, reactive, ref, toRefs } from 'vue';
 
   import { Variable } from 'apps/editor/components/variableSubstitution/types';
   import { IdentifierLocation } from 'parse/types';
@@ -46,95 +46,61 @@
     components: {
       PresentationMode
     },
-
     props: {
+      descriptionObservable: {
+        type: Object as PropType<KnockoutObservable<string | undefined>>,
+        default: undefined
+      },
       executor: {
         type: Object as PropType<Executor | null>,
         default: null
       },
-
-      titleObservable: {
-        type: Object as PropType<KnockoutObservable<string | undefined>>,
+      initialVariables: {
+        type: Object as PropType<Variable[]>,
         default: undefined
       },
-      descriptionObservable: {
+      titleObservable: {
         type: Object as PropType<KnockoutObservable<string | undefined>>,
         default: undefined
-      },
-      initialVariables: {
-        type: Object as PropType<Variable[]>,
-        default: undefined
       }
     },
+    setup(props) {
+      const subTracker = new SubscriptionTracker();
+      const { descriptionObservable, titleObservable } = toRefs(props);
+
+      const description = ref<string | null>(null);
+      const locations = reactive<IdentifierLocation[]>([]);
+      const title = ref<string | null>(null);
+
+      subTracker.trackObservable(descriptionObservable, description);
+      subTracker.trackObservable(titleObservable, title);
+
+      subTracker.subscribe(
+        POST_FROM_LOCATION_WORKER_EVENT,
+        (e: { data?: { locations?: IdentifierLocation[] } }) => {
+          if (e.data && e.data.locations) {
+            locations.splice(0, locations.length, ...e.data.locations);
+          }
+        }
+      );
 
-    setup(): {
-      subTracker: SubscriptionTracker;
-    } {
-      return {
-        subTracker: new SubscriptionTracker()
-      };
-    },
-
-    data(): {
-      locations: IdentifierLocation[];
-
-      title: string | null;
-      description: string | null;
-
-      initialized: boolean;
-    } {
       return {
-        locations: [],
-
-        title: null,
-        description: null,
-
-        initialized: false
+        description,
+        locations,
+        title
       };
     },
-
-    updated(): void {
-      if (!this.initialized && this.titleObservable && this.descriptionObservable) {
-        this.title = this.titleObservable() || null;
-        this.subTracker.subscribe(this.titleObservable, (title?: string) => {
-          this.title = title || null;
-        });
-
-        this.description = this.descriptionObservable() || null;
-        this.subTracker.subscribe(this.descriptionObservable, (description?: string) => {
-          this.description = description || null;
-        });
-
-        this.subTracker.subscribe(
-          POST_FROM_LOCATION_WORKER_EVENT,
-          (e: { data?: { locations?: IdentifierLocation[] } }) => {
-            if (e.data && e.data.locations) {
-              this.locations = e.data.locations;
-            }
-          }
-        );
-
-        this.initialized = true;
-      }
-    },
-
-    unmounted(): void {
-      this.subTracker.dispose();
-    },
-
     methods: {
       onBeforeExecute(executable: Executable): void {
         this.$el.dispatchEvent(
           new CustomEvent<Executable>('before-execute', { bubbles: true, detail: executable })
         );
       },
-
       onClose(): void {
         this.$el.dispatchEvent(
           new CustomEvent<void>('close', { bubbles: true })
         );
       },
-
       onVariablesChanged(variables: Variable[]): void {
         this.$el.dispatchEvent(
           new CustomEvent<Variable[]>('variables-changed', { bubbles: true, detail: variables })

+ 8 - 26
desktop/core/src/desktop/js/apps/editor/components/result/ResultTableKoBridge.vue

@@ -21,7 +21,7 @@
 </template>
 
 <script lang="ts">
-  import { defineComponent, PropType } from 'vue';
+  import { defineComponent, PropType, ref, toRefs } from 'vue';
 
   import { wrap } from 'vue/webComponentWrap';
 
@@ -34,41 +34,23 @@
     components: {
       ResultTable
     },
-
     props: {
       executableObservable: {
         type: Object as PropType<KnockoutObservable<SqlExecutable | null>> | null,
         default: null
       }
     },
-
-    setup() {
+    setup(props) {
       const subTracker = new SubscriptionTracker();
-      return { subTracker };
-    },
+      const { executableObservable } = toRefs(props);
 
-    data(): {
-      initialized: boolean;
-      executable: SqlExecutable | null;
-    } {
-      return {
-        initialized: false,
-        executable: null
-      };
-    },
+      const executable = ref<SqlExecutable | null>(null);
 
-    updated(): void {
-      if (!this.initialized && this.executableObservable) {
-        this.executable = this.executableObservable() || null;
-        this.subTracker.subscribe(this.executableObservable, executable => {
-          this.executable = executable || null;
-        });
-        this.initialized = true;
-      }
-    },
+      subTracker.trackObservable(executableObservable, executable);
 
-    unmounted(): void {
-      this.subTracker.dispose();
+      return {
+        executable
+      };
     }
   });
 

+ 6 - 21
desktop/core/src/desktop/js/apps/editor/components/variableSubstitution/VariableSubstitutionKoBridge.vue

@@ -26,7 +26,7 @@
 </template>
 
 <script lang="ts">
-  import { defineComponent, PropType } from 'vue';
+  import { defineComponent, PropType, reactive } from 'vue';
 
   import { wrap } from 'vue/webComponentWrap';
 
@@ -41,41 +41,26 @@
     components: {
       VariableSubstitution
     },
-
     props: {
       initialVariables: {
         type: Object as PropType<Variable[]>,
         default: undefined
       }
     },
+    setup() {
+      const subTracker = new SubscriptionTracker();
 
-    setup(): {
-      subTracker: SubscriptionTracker;
-    } {
-      return {
-        subTracker: new SubscriptionTracker()
-      };
-    },
+      const locations = reactive<IdentifierLocation[]>([]);
 
-    data(): {
-      locations: IdentifierLocation[];
-    } {
-      return {
-        locations: []
-      };
-    },
-
-    mounted(): void {
-      this.subTracker.subscribe(
+      subTracker.subscribe(
         POST_FROM_LOCATION_WORKER_EVENT,
         (e: { data?: { locations?: IdentifierLocation[] } }) => {
           if (e.data && e.data.locations) {
-            this.locations = e.data.locations;
+            locations.splice(0, locations.length, ...e.data.locations);
           }
         }
       );
     },
-
     methods: {
       onVariablesChanged(variables: Variable[]): void {
         this.$el.dispatchEvent(

+ 53 - 30
desktop/core/src/desktop/js/components/utils/SubscriptionTracker.ts

@@ -14,15 +14,40 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import { onBeforeUnmount, watch } from 'vue';
+
 import huePubSub from 'utils/huePubSub';
+import { Ref } from 'vue';
 
 export interface Disposable {
   dispose(): void;
 }
 
+const valueOrNull = <T>(val?: T): T | null => (typeof val === 'undefined' ? null : val);
+
 export default class SubscriptionTracker {
+  disposed = false;
   disposals: (() => void)[] = [];
 
+  constructor() {
+    onBeforeUnmount(this.dispose.bind(this));
+  }
+
+  addDisposable(disposable: Disposable): void {
+    this.disposals.push(disposable.dispose.bind(disposable));
+  }
+
+  addEventListener<K extends keyof HTMLElementEventMap>(
+    element: HTMLElement | Document,
+    type: K,
+    listener: (ev: HTMLElementEventMap[K]) => unknown
+  ): void {
+    element.addEventListener(type, listener as EventListenerOrEventListenerObject);
+    this.disposals.push(() => {
+      element.removeEventListener(type, listener as EventListenerOrEventListenerObject);
+    });
+  }
+
   subscribe(
     subscribable: string | KnockoutSubscribable<unknown>,
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -41,47 +66,45 @@ export default class SubscriptionTracker {
     }
   }
 
-  async whenDefined<T>(observable: KnockoutSubscribable<T | undefined>): Promise<T> {
-    return new Promise((resolve, reject) => {
-      let disposed = false;
-      const sub = observable.subscribe(val => {
-        if (typeof val !== 'undefined') {
-          disposed = true;
-          sub.dispose();
-          resolve(val);
-        }
-      });
-      this.disposals.push(() => {
-        if (!disposed) {
-          sub.dispose();
-          reject();
-        }
+  /**
+   * Helper function to link knockout observable with Vue refs. Note that it will update the Vue ref on changes to
+   * the knockout observable but not the other way around.
+   */
+  trackObservable<T>(
+    observableRef: Ref<KnockoutObservable<T | undefined | null>>,
+    vueRef: Ref<T | null>
+  ): void {
+    const whenDef = new Promise<KnockoutObservable<T | null | undefined>>(resolve => {
+      const stop = watch(
+        observableRef,
+        observable => {
+          if (observable) {
+            resolve(observable);
+            stop();
+          }
+        },
+        { immediate: true }
+      );
+    });
+    whenDef.then(observable => {
+      if (this.disposed) {
+        return;
+      }
+      vueRef.value = valueOrNull(observable());
+      this.subscribe(observable, (newVal?: T) => {
+        vueRef.value = valueOrNull(newVal);
       });
     });
   }
 
-  addDisposable(disposable: Disposable): void {
-    this.disposals.push(disposable.dispose.bind(disposable));
-  }
-
   trackTimeout(timeout: number): void {
     this.disposals.push(() => {
       window.clearTimeout(timeout);
     });
   }
 
-  addEventListener<K extends keyof HTMLElementEventMap>(
-    element: HTMLElement | Document,
-    type: K,
-    listener: (ev: HTMLElementEventMap[K]) => unknown
-  ): void {
-    element.addEventListener(type, listener as EventListenerOrEventListenerObject);
-    this.disposals.push(() => {
-      element.removeEventListener(type, listener as EventListenerOrEventListenerObject);
-    });
-  }
-
   dispose(): void {
+    this.disposed = true;
     while (this.disposals.length) {
       try {
         const disposeFn = this.disposals.pop();