diff options
Diffstat (limited to 'plugins/CoreHome/vue/src/SiteSelector')
5 files changed, 808 insertions, 0 deletions
diff --git a/plugins/CoreHome/vue/src/SiteSelector/AllSitesLink.vue b/plugins/CoreHome/vue/src/SiteSelector/AllSitesLink.vue new file mode 100644 index 0000000000..548a697403 --- /dev/null +++ b/plugins/CoreHome/vue/src/SiteSelector/AllSitesLink.vue @@ -0,0 +1,36 @@ +<!-- + Matomo - free/libre analytics platform + @link https://matomo.org + @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later +--> + +<template> + <div + @click="this.onClick($event)" + class="custom_select_all" + > + <a + @click="$event.preventDefault()" + v-html="$sanitize(allSitesText)" + tabindex="4" + :href="href" + /> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + href: String, + allSitesText: String, + }, + emits: ['click'], + methods: { + onClick(event: MouseEvent) { + this.$emit('click', event); + }, + }, +}); +</script> diff --git a/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.adapter.ts b/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.adapter.ts new file mode 100644 index 0000000000..b3438d2bb7 --- /dev/null +++ b/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.adapter.ts @@ -0,0 +1,86 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { INgModelController, ITimeoutService } from 'angular'; +import createAngularJsAdapter from '../createAngularJsAdapter'; +import SiteSelector from './SiteSelector.vue'; +import Matomo from '../Matomo/Matomo'; + +export default createAngularJsAdapter<[ITimeoutService]>({ + component: SiteSelector, + require: '?ngModel', + scope: { + showSelectedSite: { + angularJsBind: '=', + }, + showAllSitesItem: { + angularJsBind: '=', + }, + switchSiteOnSelect: { + angularJsBind: '=', + }, + onlySitesWithAdminAccess: { + angularJsBind: '=', + }, + name: { + angularJsBind: '@', + }, + allSitesText: { + angularJsBind: '@', + }, + allSitesLocation: { + angularJsBind: '@', + }, + placeholder: { + angularJsBind: '@', + }, + modelValue: {}, + }, + $inject: ['$timeout'], + directiveName: 'piwikSiteselector', + events: { + 'update:modelValue': (newValue, vm, scope, element, attrs, ngModel) => { + if ((newValue && !vm.modelValue) + || (!newValue && vm.modelValue) + || newValue.id !== vm.modelValue.id + ) { + element.attr('siteid', newValue.id); + element.trigger('change', newValue); + + if (ngModel) { + ngModel.$setViewValue(newValue); + } + } + }, + blur(event, vm, scope) { + setTimeout(() => scope.$apply()); + }, + }, + postCreate(vm, scope, element, attrs, controller, $timeout: ITimeoutService) { + const ngModel = controller as INgModelController; + + // setup ng-model mapping + if (ngModel) { + ngModel.$setViewValue(vm.modelValue); + + ngModel.$render = () => { + if (angular.isString(ngModel.$viewValue)) { + vm.modelValue = JSON.parse(ngModel.$viewValue); + } else { + vm.modelValue = ngModel.$viewValue; + } + }; + } + + $timeout(() => { + if (attrs.siteid && attrs.sitename) { + vm.modelValue = { id: attrs.siteid, name: Matomo.helper.htmlDecode(attrs.sitename) }; + ngModel.$setViewValue({ ...vm.modelValue }); + } + }); + }, +}); diff --git a/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.less b/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.less new file mode 100644 index 0000000000..48700196c2 --- /dev/null +++ b/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.less @@ -0,0 +1,185 @@ +.autocompleteMatched { + color: #5256BE; + font-weight: bold; +} +.siteSelector { + a.title { + .icon.collapsed.iconHidden { + visibility: visible; + } + + span.placeholder { + color: #9e9e9e; + font-style: italic; + } + } + .dropdown { + min-width: 210px; + } + + .ui-widget.ui-widget-content { + border: none; + } +} + +#content { + .sites_autocomplete { + position: static !important; + height: 36px; + z-index: 99; + vertical-align: middle; + + > .siteSelector { + position: absolute; + z-index: 998; + } + + a.title { + text-decoration: none; + } + } +} + +.siteSelector.expanded { + .loading { + background: url(plugins/Morpheus/images/loading-blue.gif) no-repeat 16% 11px; + } +} + +.siteSelector a.title, +.siteSelector .custom_select_ul_list li a, +.siteSelector .custom_select_all a, +.siteSelector .custom_select_main_link > span { + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0; + color: @theme-color-text; + text-transform: uppercase; + width: 100%; +} + +.siteSelector.piwikSelector a.title { + padding: 10px 15px 11px 13px; + + /*.icon:not(.icon-fixed) { + margin-right: -11px; + }*/ + + > span { + max-width: 161px; + overflow: hidden; + text-overflow: ellipsis; + } + + span { + vertical-align: top; + } +} + +.siteSelector .custom_select_ul_list, +.siteSelector ul.ui-autocomplete { + position: relative; + list-style: none; + line-height: 18px; + padding: 0 0 15px 0; + box-shadow: none !important; +} + +.siteSelector .custom_select_ul_list { + padding: 0 0 5px 0; +} + +.siteSelector .dropdown { + padding-top: 0; +} + +.siteSelector .custom_select_ul_list li a, +.siteSelector .custom_select_all a { + line-height: 18px; + height: auto; + display: block; + text-decoration: none; + padding-left: 5px; +} + +.siteSelector .custom_select_ul_list li a:hover, +.siteSelector .custom_select_all a:hover { + background: @color-silver-l95; +} + +.siteSelector .custom_select_all a { + text-decoration: none; + &:hover { + text-decoration: none; + } +} + +.siteSelector .custom_select_search { + margin: 0 0 8px 0; + height: 33px; + display: block; + white-space: nowrap; + position: relative; + + .inp { + vertical-align: top; + width: 100%; + padding: 7px 6px !important; + border: 1px solid #d0d0d0 !important; + background: transparent !important; + font-size: 11px !important; + color: #454545 !important; + } + .reset { + position: absolute; + top: 8px; + right: 4px; + cursor: pointer; + } +} + +.siteSelector { + width: auto; +} + +.sites_selector_container>.siteSelector { + padding-left: 12px; +} + +.custom_selector_container .ui-menu-item, +.custom_selector_container .ui-menu-item a { + float:none;position:static +} + +.custom_select_block_show { + height: auto; + overflow: visible; + max-width:inherit; +} + +.sites_selector_container { + padding-top: 5px; +} + +.siteSelect a { + white-space: normal; + text-align: left; +} + +.siteSelector.disabled { + a.title { + cursor: default !important; + + .icon { + display: none !important; + } + } + + &.borderedControl { + &:hover { + background-color: @theme-color-background-base!important; + } + } +}
\ No newline at end of file diff --git a/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.vue b/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.vue new file mode 100644 index 0000000000..52884f6815 --- /dev/null +++ b/plugins/CoreHome/vue/src/SiteSelector/SiteSelector.vue @@ -0,0 +1,370 @@ +<!-- + Matomo - free/libre analytics platform + @link https://matomo.org + @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later +--> + +<template> + <div + class="siteSelector piwikSelector borderedControl" + :class="{'expanded': showSitesList, 'disabled': !hasMultipleSites}" + v-focus-anywhere-but-here="{ blur: onBlur }" + > + <input + v-if="name" + type="hidden" + :value="selectedSite?.id" + :name="name" + /> + <a + ref="selectorLink" + @click="onClickSelector" + @keydown="onPressEnter($event)" + href="javascript:void(0)" + :class="{'loading': isLoading}" + class="title" + tabindex="4" + :title="selectorLinkTitle" + > + <span + class="icon icon-arrow-bottom" + :class="{'iconHidden': isLoading, 'collapsed': !showSitesList}" + /> + <span> + <span + v-text="selectedSite?.name || firstSiteName" + v-if="selectedSite?.name || !placeholder" + /> + <span + v-if="!selectedSite?.name && placeholder" + class="placeholder" + >{{ placeholder }}</span> + </span> + </a> + <div + v-show="showSitesList" + class="dropdown" + > + <div + class="custom_select_search" + v-show="autocompleteMinSites <= sites.length || searchTerm" + > + <input + type="text" + @click="searchTerm = '';loadInitialSites()" + v-model="searchTerm" + @keydown="onSearchInputKeydown()" + tabindex="4" + class="websiteSearch inp browser-default" + v-focus-if:[shouldFocusOnSearch]="{}" + :placeholder="translate('General_Search')" + /> + <img + title="Clear" + v-show="searchTerm" + @click="searchTerm = '';loadInitialSites()" + class="reset" + src="plugins/CoreHome/images/reset_search.png" + /> + </div> + <div v-if="allSitesLocation === 'top' && showAllSitesItem"> + <AllSitesLink + :href="urlAllSites" + :all-sites-text="allSitesText" + @click="onAllSitesClick($event)" + /> + </div> + <div class="custom_select_container"> + <ul + class="custom_select_ul_list" + @click="showSitesList = false" + > + <li + @click="switchSite(site, $event)" + v-show="!(!showSelectedSite && activeSiteId === site.idsite)" + v-for="site in sites" + :key="site.idsite" + > + <a + @click="$event.preventDefault()" + v-html="$sanitize(getMatchedSiteName(site.name))" + tabindex="4" + :href="getUrlForSiteId(site.idsite)" + :title="site.name" + /> + </li> + </ul> + <ul + v-show="!sites.length && searchTerm" + class="ui-autocomplete ui-front ui-menu ui-widget ui-widget-content ui-corner-all + siteSelect" + > + <li class="ui-menu-item"> + <a + class="ui-corner-all" + tabindex="-1" + > + {{ translate('SitesManager_NotFound') + ' ' + searchTerm }} + </a> + </li> + </ul> + </div> + <div v-if="allSitesLocation === 'bottom' && showAllSitesItem"> + <AllSitesLink + :href="urlAllSites" + :all-sites-text="allSitesText" + @click="onAllSitesClick($event)" + /> + </div> + </div> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FocusAnywhereButHere from '../FocusAnywhereButHere/FocusAnywhereButHere'; +import FocusIf from '../FocusIf/FocusIf'; +import AllSitesLink from './AllSitesLink.vue'; +import Matomo from '../Matomo/Matomo'; +import MatomoUrl from '../MatomoUrl/MatomoUrl'; +import translate from '../translate'; +import SitesStore, { Site } from './SitesStore'; +import debounce from '../debounce'; + +interface SiteRef { + id: string|number; + name: string; +} + +interface SiteSelectorState { + searchTerm: string; + showSitesList: boolean; + isLoading: boolean; + sites: Site[]; + selectedSite: SiteRef; + autocompleteMinSites: null|number; +} + +export default defineComponent({ + props: { + modelValue: { + Object, + default: { + id: Matomo.idSite, + name: Matomo.helper.htmlDecode(Matomo.siteName), + }, + }, + showSelectedSite: { + type: Boolean, + default: false, + }, + showAllSitesItem: { + type: Boolean, + default: true, + }, + switchSiteOnSelect: { + type: Boolean, + default: true, + }, + onlySitesWithAdminAccess: { + type: Boolean, + default: false, + }, + name: { + type: String, + default: '', + }, + allSitesText: { + type: String, + default: translate('General_MultiSitesSummary'), + }, + allSitesLocation: { + type: String, + default: 'bottom', + }, + placeholder: String, + }, + emits: ['update:modelValue', 'blur'], + components: { + AllSitesLink, + }, + directives: { + FocusAnywhereButHere, + FocusIf, + }, + watch: { + modelValue: { + handler(newValue) { + this.selectedSite = { ...newValue }; + }, + deep: true, + }, + }, + data(): SiteSelectorState { + return { + searchTerm: '', + activeSiteId: Matomo.idSite, + showSitesList: false, + isLoading: false, + sites: [], + selectedSite: { + id: Matomo.idSite, + name: Matomo.helper.htmlDecode(Matomo.siteName), + }, + autocompleteMinSites: parseInt(Matomo.config.autocomplete_min_sites as string, 10), + }; + }, + mounted() { + window.initTopControls(); + + this.loadInitialSites().then(() => { + if ((!this.selectedSite || !this.selectedSite.id) && this.sites[0]) { + this.selectedSite = { id: this.sites[0].idsite, name: this.sites[0].name }; + this.$emit('update:modelValue', { ...this.selectedSite }); + } + }); + + const shortcutTitle = translate('CoreHome_ShortcutWebsiteSelector'); + Matomo.helper.registerShortcut('w', shortcutTitle, (event) => { + if (event.altKey) { + return; + } + if (event.preventDefault) { + event.preventDefault(); + } else { + event.returnValue = false; // IE + } + this.$refs.selectorLink.click(); + this.$refs.selectorLink.focus(); + }); + }, + created() { + this.onSearchInputKeydown = debounce(this.onSearchInputKeydown.bind(this)); + }, + computed: { + shouldFocusOnSearch() { + return (this.showSitesList && this.autocompleteMinSites <= this.sites.length) + || this.searchTerm; + }, + selectorLinkTitle() { + return this.hasMultipleSites + ? translate('CoreHome_ChangeCurrentWebsite', this.selectedSite?.name || this.firstSiteName) + : ''; + }, + hasMultipleSites() { + return SitesStore.initialSites.value && SitesStore.initialSites.value.length > 1; + }, + firstSiteName() { + return this.sites && this.sites.length > 0 ? this.sites[0].name : ''; + }, + urlAllSites() { + const newQuery = MatomoUrl.stringify({ + ...MatomoUrl.urlParsed.value, + module: 'MultiSites', + action: 'index', + date: MatomoUrl.parsed.value.date, + period: MatomoUrl.parsed.value.period, + }); + return `?${newQuery}`; + }, + }, + methods: { + onAllSitesClick(event: MouseEvent) { + this.switchSite({ idsite: 'all', name: this.allSitesText }, event); + this.showSitesList = false; + }, + switchSite(site: SiteRef, event: KeyboardEvent|MouseEvent) { + // for Mac OS cmd key needs to be pressed, ctrl key on other systems + const controlKey = navigator.userAgent.indexOf('Mac OS X') !== -1 ? event.metaKey : event.ctrlKey; + + if (event && controlKey && event.target && (event.target as HTMLLinkElement).href) { + window.open((event.target as HTMLLinkElement).href, '_blank'); + return; + } + + this.selectedSite = { id: site.idsite, name: site.name }; + this.$emit('update:modelValue', { ...this.selectedSite }); + + if (!this.switchSiteOnSelect || this.activeSiteId === site.idsite) { + return; + } + + SitesStore.loadSite(site.idsite); + }, + onBlur() { + this.showSitesList = false; + this.$emit('blur'); + }, + onClickSelector() { + if (this.hasMultipleSites) { + this.showSitesList = !this.showSitesList; + + if (!this.isLoading && !this.searchTerm) { + this.loadInitialSites(); + } + } + }, + onPressEnter(event: KeyboardEvent) { + if (event.key !== 'Enter') { + return; + } + + event.preventDefault(); + + this.showSitesList = !this.showSitesList; + if (this.showSitesList && !this.isLoading) { + this.loadInitialSites(); + } + }, + onSearchInputKeydown() { + setTimeout(() => { + this.searchSite(this.searchTerm); + }); + }, + getMatchedSiteName(siteName: string) { + const index = siteName.toUpperCase().indexOf(this.searchTerm.toUpperCase()); + if (index === -1) { + return Matomo.helper.htmlEntities(siteName); + } + + const previousPart = Matomo.helper.htmlEntities(siteName.substring(0, index)); + const lastPart = Matomo.helper.htmlEntities( + siteName.substring(index + this.searchTerm.length), + ); + + return `${previousPart}<span class="autocompleteMatched">${this.searchTerm}</span>${lastPart}`; + }, + loadInitialSites() { + return SitesStore.loadInitialSites().then((sites) => { + this.sites = sites || []; + }); + }, + searchSite(term: string) { + this.isLoading = true; + + SitesStore.searchSite(term, this.onlySitesWithAdminAccess).then((sites) => { + if (sites) { + this.sites = sites; + } + }).finally(() => { + this.isLoading = false; + }); + }, + getUrlForSiteId(idSite: string|number) { + const newQuery = MatomoUrl.stringify({ + ...MatomoUrl.urlParsed.value, + segment: '', + idSite, + }); + + const newHash = MatomoUrl.stringify({ + ...MatomoUrl.hashParsed.value, + segment: '', + idSite, + }); + + return `?${newQuery}#?${newHash}`; + }, + }, +}); +</script> diff --git a/plugins/CoreHome/vue/src/SiteSelector/SitesStore.ts b/plugins/CoreHome/vue/src/SiteSelector/SitesStore.ts new file mode 100644 index 0000000000..655803843c --- /dev/null +++ b/plugins/CoreHome/vue/src/SiteSelector/SitesStore.ts @@ -0,0 +1,131 @@ +/*! + * Matomo - free/libre analytics platform + * + * @link https://matomo.org + * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +import { reactive, computed, readonly } from 'vue'; +import AjaxHelper from '../AjaxHelper/AjaxHelper'; +import MatomoUrl from '../MatomoUrl/MatomoUrl'; + +export interface Site { + idsite: string; + name: string; +} + +interface SitesStoreState { + initialSites: Site[]|null; + isInitialized: boolean; +} + +class SitesStore { + private state = reactive<SitesStoreState>({ + initialSites: [], + isInitialized: false, + }); + + private currentRequest: AbortablePromise; + + private limitRequest: AbortablePromise; + + public readonly initialSites = computed(() => readonly(this.state.initialSites)); + + loadInitialSites(): Promise<Site[]> { + if (this.state.isInitialized) { + return Promise.resolve(readonly(this.state.initialSites)); + } + + return this.searchSite('%').then((sites) => { + this.state.isInitialized = true; + this.state.initialSites = sites; + return readonly(sites); + }); + } + + loadSite(idSite: number|string): void { + if (idSite === 'all') { + MatomoUrl.updateUrl({ + ...MatomoUrl.urlParsed.value, + module: 'MultiSites', + action: 'index', + date: MatomoUrl.parsed.value.date, + period: MatomoUrl.parsed.value.period, + }); + } else { + MatomoUrl.updateUrl({ + ...MatomoUrl.parsed.value, + segment: '', + idSite, + }); + } + } + + searchSite(term, onlySitesWithAdminAccess = false): Promise<Site[]> { + if (!term) { + return this.loadInitialSites(); + } + + if (this.currentRequest) { + this.currentRequest.abort(); + } + + if (this.limitRequest) { + this.limitRequest.abort(); + this.limitRequest = null; + } + + if (!this.limitRequest) { + this.limitRequest = AjaxHelper.fetch({ method: 'SitesManager.getNumWebsitesToDisplayPerPage' }); + } + + return this.limitRequest.then((response) => { + const limit = response.value; + + let methodToCall = 'SitesManager.getPatternMatchSites'; + if (onlySitesWithAdminAccess) { + methodToCall = 'SitesManager.getSitesWithAdminAccess'; + } + + this.currentRequest = AjaxHelper.fetch({ + method: methodToCall, + limit, + pattern: term, + }); + + return this.currentRequest; + }).then((response) => { + if (response) { + return this.processWebsitesList(response); + } + + return null; + }).finally(() => { + this.currentRequest = null; + }); + } + + private processWebsitesList(response) { + let sites = response; + + if (!sites || !sites.length) { + return []; + } + + sites = sites.map((s) => ({ + ...s, + name: s.group ? `[${s.group}] ${s.name}` : s.name, + })); + + sites.sort((lhs, rhs) => { + if (lhs.name.toLowerCase() < rhs.name.toLowerCase()) { + return -1; + } + return lhs.name.toLowerCase() > rhs.name.toLowerCase() ? 1 : 0; + }); + + return sites; + } +} + +export default new SitesStore(); |