diff options
author | dizzy <diosmosis@users.noreply.github.com> | 2021-10-15 09:08:14 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-10-15 09:08:14 +0300 |
commit | 2f80606b1db3caaeb4195ff7cd0bf949b2154968 (patch) | |
tree | 3ebe7333835309b135395a3b31496f9bee93c9ed /plugins/CoreHome/vue/src | |
parent | 71c98f36ec238b6d688065963f196c387850da53 (diff) |
[Vue] utility function for creating angularjs adapters (#18146)4.6.0-b1
* incomplete conversion
* get ajax helper migration to work
* delete old periods.spec.js
* remove global-ajax-queue.js file
* migrate piwik service and test (w/ hacks to get it the test to work)
* rebuild and remove old files + get tests to pass
* unfinished commit
* return jqxhr object so promise api can be used
* move hasBlockedContent and deprecate piwikApi service
* remove alert files
* convert piwikHelper.spec.js
* in new vue code, use "Matomo" everywhere possible instead of "piwik" and rebuild vue files
* add another needed export line in command
* include polyfills after vue so we can add to vue engine
* Add HTML sanitizer for use w/ migrating ng-bind-html uses.
* fix broken merge, rebuild js, fix issue in build command
* add sanitize to other components for consistency (will be replaced by utility function eventually)
* migrate matomo-dialog, fix issue where vue:build --watch did not correctly watch all plugin files, fix path issue in webpack externals, add vue matomo dialog use example to ExampleVue
* update expected screenshot
* create initial createAngularJsAdapter generic function and use for AcitivityIndicator
* fix webpack chunk loading issue that occurs only on production (since the chunk is not stored in the same directory as the merged asset JS)
* use adapter utility for Alert
* use adapter function for matomo-dialog (even though the mapping becomes more complex)
* fix unit tests
Diffstat (limited to 'plugins/CoreHome/vue/src')
6 files changed, 332 insertions, 94 deletions
diff --git a/plugins/CoreHome/vue/src/ActivityIndicator/ActivityIndicator.adapter.ts b/plugins/CoreHome/vue/src/ActivityIndicator/ActivityIndicator.adapter.ts index 0ca0ac8241..84cb965fc6 100644 --- a/plugins/CoreHome/vue/src/ActivityIndicator/ActivityIndicator.adapter.ts +++ b/plugins/CoreHome/vue/src/ActivityIndicator/ActivityIndicator.adapter.ts @@ -5,51 +5,23 @@ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -import { createApp } from 'vue'; import ActivityIndicator from './ActivityIndicator.vue'; import translate from '../translate'; +import createAngularJsAdapter from '../createAngularJsAdapter'; -interface ActivityIndicatorAdapterScope extends ng.IScope { - loading: boolean; - loadingMessage: string; -} - -export default function activityIndicatorAdapter(): ng.IDirective { - return { - restrict: 'A', - scope: { - loading: '<', - loadingMessage: '<', +export default createAngularJsAdapter({ + component: ActivityIndicator, + scope: { + loading: { + vue: 'loading', + angularJsBind: '<', }, - template: '', - link: function activityIndicatorAdapterLink( - scope: ActivityIndicatorAdapterScope, - element: ng.IAugmentedJQuery, - ) { - const app = createApp({ - template: '<activity-indicator :loading="loading" :loadingMessage="loadingMessage"/>', - data() { - return { - loading: scope.loading, - loadingMessage: scope.loadingMessage, - }; - }, - }); - app.config.globalProperties.$sanitize = window.vueSanitize; - app.component('activity-indicator', ActivityIndicator); - const vm = app.mount(element[0]); - - scope.$watch('loading', (newValue: boolean) => { - vm.loading = newValue; - }); - - scope.$watch('loadingMessage', (newValue: string) => { - vm.loadingMessage = newValue || translate('General_LoadingData'); - }); + loadingMessage: { + vue: 'loadingMessage', + angularJsBind: '<', + default: () => translate('General_LoadingData'), }, - }; -} - -activityIndicatorAdapter.$inject = []; - -angular.module('piwikApp').directive('piwikActivityIndicator', activityIndicatorAdapter); + }, + $inject: [], + directiveName: 'piwikActivityIndicator', +}); diff --git a/plugins/CoreHome/vue/src/Alert/Alert.adapter.ts b/plugins/CoreHome/vue/src/Alert/Alert.adapter.ts index 5cecb97a81..c100116645 100644 --- a/plugins/CoreHome/vue/src/Alert/Alert.adapter.ts +++ b/plugins/CoreHome/vue/src/Alert/Alert.adapter.ts @@ -5,56 +5,17 @@ * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later */ -import { createApp, ref } from 'vue'; import Alert from './Alert.vue'; - -interface AlertAdapterScope extends ng.IScope { - severity: string; -} - -export default function alertAdapter(): ng.IDirective { - return { - restrict: 'A', - transclude: true, - scope: { - severity: '@piwikAlert', - }, - template: '<div ng-transclude/>', - compile: function alertAdapterCompile() { - return { - post: function alertAdapterPostLink( - scope: AlertAdapterScope, - element: ng.IAugmentedJQuery, - ) { - const clone = element.find('[ng-transclude]'); - - const app = createApp({ - template: '<alert :severity="severity"><div ref="transcludeTarget"/></alert>', - data() { - return { severity: scope.severity }; - }, - setup() { - const transcludeTarget = ref(null); - return { - transcludeTarget, - }; - }, - }); - app.config.globalProperties.$sanitize = window.vueSanitize; - app.component('alert', Alert); - const vm = app.mount(element[0]); - - scope.$watch('severity', (newValue: string) => { - vm.severity = newValue; - }); - - $(vm.transcludeTarget).append(clone); - }, - }; +import createAngularJsAdapter from '../createAngularJsAdapter'; + +export default createAngularJsAdapter({ + component: Alert, + scope: { + severity: { + vue: 'severity', + angularJsBind: '@piwikAlert', }, - }; -} - -alertAdapter.$inject = []; - -angular.module('piwikApp').directive('piwikAlert', alertAdapter); + }, + directiveName: 'piwikAlert', + transclude: true, +}); diff --git a/plugins/CoreHome/vue/src/MatomoDialog/MatomoDialog.adapter.ts b/plugins/CoreHome/vue/src/MatomoDialog/MatomoDialog.adapter.ts new file mode 100644 index 0000000000..0b318173e7 --- /dev/null +++ b/plugins/CoreHome/vue/src/MatomoDialog/MatomoDialog.adapter.ts @@ -0,0 +1,64 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { IParseService } from 'angular'; +import { ComponentPublicInstance } from 'vue'; +import MatomoDialog from './MatomoDialog.vue'; +import createAngularJsAdapter from '../createAngularJsAdapter'; + +export default createAngularJsAdapter<[IParseService]>({ + component: MatomoDialog, + scope: { + show: { + vue: 'show', + default: false, + }, + element: { + vue: 'element', + default: (scope, element) => element[0], + }, + }, + events: { + yes: (scope, element, attrs) => { + if (attrs.yes) { + scope.$eval(attrs.yes); + setTimeout(() => { scope.$apply(); }, 0); + } + }, + no: (scope, element, attrs) => { + if (attrs.no) { + scope.$eval(attrs.no); + setTimeout(() => { scope.$apply(); }, 0); + } + }, + close: (scope, element, attrs) => { + if (attrs.close) { + scope.$eval(attrs.close); + setTimeout(() => { scope.$apply(); }, 0); + } + }, + closeEnd: (scope, element, attrs, $parse: IParseService) => { + setTimeout(() => { + scope.$apply($parse(attrs.piwikDialog).assign(scope, false)); + }, 0); + }, + }, + $inject: ['$parse'], + directiveName: 'piwikDialog', + transclude: true, + mountPointFactory: (scope, element) => { + const vueRootPlaceholder = $('<div class="vue-placeholder"/>'); + vueRootPlaceholder.appendTo(element); + return vueRootPlaceholder[0]; + }, + postCreate: (vm: ComponentPublicInstance, scope, element, attrs) => { + scope.$watch(attrs.piwikDialog, (newValue: boolean) => { + vm.show = newValue || false; + }); + }, + noScope: true, +}); diff --git a/plugins/CoreHome/vue/src/MatomoDialog/MatomoDialog.vue b/plugins/CoreHome/vue/src/MatomoDialog/MatomoDialog.vue new file mode 100644 index 0000000000..5a46841cbf --- /dev/null +++ b/plugins/CoreHome/vue/src/MatomoDialog/MatomoDialog.vue @@ -0,0 +1,58 @@ +<!-- + Matomo - free/libre analytics platform + + @link https://matomo.org + @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later +--> +<template> + <slot></slot> +</template> +<script lang="ts"> +import { defineComponent } from 'vue'; +import Matomo from '../Matomo/Matomo'; + +export default defineComponent({ + props: { + /** + * Whether the modal is displayed or not; + */ + show: { + type: Boolean, + required: true, + }, + + /** + * Only here for backwards compatibility w/ AngularJS. If supplied, we use this + * element to launch the modal instead of the element in the slot. This should not + * be used for new Vue code. + * + * @deprecated + */ + element: { + type: HTMLElement, + required: false, + }, + }, + emits: ['yes', 'no', 'closeEnd', 'close'], + activated() { + const slotElement = this.element || this.$slots.default()[0].el; + slotElement.style.display = 'none'; + }, + watch: { + show(newValue, oldValue) { + if (newValue) { + const slotElement = this.element || this.$slots.default()[0].el; + Matomo.helper.modalConfirm(slotElement, { + yes: () => { this.$emit('yes'); }, + no: () => { this.$emit('no'); }, + }, { + onCloseEnd: () => { this.$emit('closeEnd'); }, + }); + } else if (newValue === false && oldValue === true) { + // the user closed the dialog, e.g. by pressing Esc or clicking away from it + this.$emit('close'); + } + }, + }, +}); +</script> diff --git a/plugins/CoreHome/vue/src/createAngularJsAdapter.ts b/plugins/CoreHome/vue/src/createAngularJsAdapter.ts new file mode 100644 index 0000000000..9d7ba86cea --- /dev/null +++ b/plugins/CoreHome/vue/src/createAngularJsAdapter.ts @@ -0,0 +1,180 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { + createApp, + defineComponent, + ref, + ComponentPublicInstance, +} from 'vue'; + +interface SingleScopeVarInfo { + vue: string; + default?: any; // eslint-disable-line + angularJsBind?: string; +} + +type ScopeMapping = { [scopeVarName: string]: SingleScopeVarInfo }; + +type AdapterFunction<InjectTypes, R = void> = ( + scope: ng.IScope, + element: ng.IAugmentedJQuery, + attrs: ng.IAttributes, + ...injected: InjectTypes, +) => R; + +type PostCreateFunction<InjectTypes, R = void> = ( + vm: ComponentPublicInstance, + scope: ng.IScope, + element: ng.IAugmentedJQuery, + attrs: ng.IAttributes, + ...injected: InjectTypes, +) => R; + +type EventMapping<InjectTypes> = { [vueEventName: string]: AdapterFunction<InjectTypes> }; + +type ComponentType = ReturnType<typeof defineComponent>; + +export default function createAngularJsAdapter<InjectTypes = []>(options: { + component: ComponentType, + scope?: ScopeMapping, + directiveName: string, + events?: EventMapping<InjectTypes>, + $inject?: string[], + transclude?: boolean, + mountPointFactory?: AdapterFunction<InjectTypes, HTMLElement>, + postCreate?: PostCreateFunction<InjectTypes>, + noScope?: boolean, +}): ng.IDirectiveFactory { + const { + component, + scope = {}, + events = {}, + $inject, + directiveName, + transclude, + mountPointFactory, + postCreate, + noScope, + } = options; + + const angularJsScope = {}; + Object.entries(scope).forEach(([scopeVarName, info]) => { + if (info.angularJsBind) { + angularJsScope[scopeVarName] = info.angularJsBind; + } + }); + + function angularJsAdapter(...injectedServices: InjectTypes) { + const adapter: ng.IDirective = { + restrict: 'A', + scope: noScope ? undefined : angularJsScope, + compile: function angularJsAdapterCompile() { + return { + post: function angularJsAdapterLink( + ngScope: ng.IScope, + ngElement: ng.IAugmentedJQuery, + ngAttrs: ng.IAttributes, + ) { + const clone = ngElement.find('[ng-transclude]'); + + let rootVueTemplate = '<root-component'; + Object.entries(scope).forEach(([, info]) => { + rootVueTemplate += ` :${info.vue}="${info.vue}"`; + }); + Object.entries(events).forEach((info) => { + const [eventName] = info; + rootVueTemplate += ` @${eventName}="onEventHandler('${eventName}')"`; + }); + rootVueTemplate += '>'; + if (transclude) { + rootVueTemplate += '<div ref="transcludeTarget"/>'; + } + rootVueTemplate += '</root-component>'; + const app = createApp({ + template: rootVueTemplate, + data() { + const initialData = {}; + Object.entries(scope).forEach(([scopeVarName, info]) => { + let value = ngScope[scopeVarName]; + if (typeof value === 'undefined' && typeof info.default !== 'undefined') { + value = info.default instanceof Function + ? info.default(ngScope, ngElement, ngAttrs, ...injectedServices) + : info.default; + } + initialData[info.vue] = value; + }); + return initialData; + }, + setup() { + if (transclude) { + const transcludeTarget = ref(null); + return { + transcludeTarget, + }; + } + + return undefined; + }, + methods: { + onEventHandler(name: string) { + if (events[name]) { + events[name](ngScope, ngElement, ngAttrs, ...injectedServices); + } + }, + }, + }); + app.config.globalProperties.$sanitize = window.vueSanitize; + app.component('root-component', component); + + const mountPoint = mountPointFactory + ? mountPointFactory(ngScope, ngElement, ngAttrs, ...injectedServices) + : ngElement[0]; + const vm = app.mount(mountPoint); + + Object.entries(scope).forEach(([scopeVarName, info]) => { + if (!info.angularJsBind) { + return; + } + + ngScope.$watch(scopeVarName, (newValue: any) => { // eslint-disable-line + if (typeof info.default !== 'undefined' && typeof newValue === 'undefined') { + vm[scopeVarName] = info.default instanceof Function + ? info.default(ngScope, ngElement, ngAttrs, ...injectedServices) + : info.default; + } else { + vm[scopeVarName] = newValue; + } + }); + }); + + if (transclude) { + $(vm.transcludeTarget).append(clone); + } + + if (postCreate) { + postCreate(vm, ngScope, ngElement, ngAttrs, ...injectedServices); + } + }, + }; + }, + }; + + if (transclude) { + adapter.transclude = true; + adapter.template = '<div ng-transclude/>'; + } + + return adapter; + } + + angularJsAdapter.$inject = $inject || []; + + angular.module('piwikApp').directive(directiveName, angularJsAdapter); + + return angularJsAdapter; +} diff --git a/plugins/CoreHome/vue/src/index.ts b/plugins/CoreHome/vue/src/index.ts index d0b4b8a8fb..388204a6bd 100644 --- a/plugins/CoreHome/vue/src/index.ts +++ b/plugins/CoreHome/vue/src/index.ts @@ -17,7 +17,9 @@ import './Periods/Periods.adapter'; import './AjaxHelper/AjaxHelper.adapter'; import './PiwikUrl/PiwikUrl.adapter'; import './Piwik/Piwik.adapter'; +import './MatomoDialog/MatomoDialog.adapter'; +export { default as createAngularJsAdapter } from './createAngularJsAdapter'; export { default as activityIndicatorAdapter } from './ActivityIndicator/ActivityIndicator.adapter'; export { default as ActivityIndicator } from './ActivityIndicator/ActivityIndicator.vue'; export { default as translate } from './translate'; @@ -26,3 +28,4 @@ export { default as AjaxHelper } from './AjaxHelper/AjaxHelper'; export { default as MatomoUrl } from './MatomoUrl/MatomoUrl'; export { default as Matomo } from './Matomo/Matomo'; export * from './Periods'; +export { default as MatomoDialog } from './MatomoDialog/MatomoDialog.vue'; |